hypertabular-wasm 0.7.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +179 -0
- data/ext/hypertabular_native/3.4/hypertabular_native.a.gz +0 -0
- data/ext/hypertabular_native/4.0/hypertabular_native.a.gz +0 -0
- data/ext/hypertabular_native/extconf.rb +108 -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 +107 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
module HyperTabular
|
|
2
|
+
module Runtime
|
|
3
|
+
# The core's delimited reader as one object: every byte of native memory a read needs
|
|
4
|
+
# and every native call it makes. This class is the whole Fiddle crossing — the reader
|
|
5
|
+
# above it (DelimitedReader) is plain Ruby that never sees a pointer — and so it is the
|
|
6
|
+
# one thing a compiled extension would replace: same methods, bytes in and bytes out.
|
|
7
|
+
#
|
|
8
|
+
# The memory is allocated once and reused for every batch: the state block, the plan,
|
|
9
|
+
# one value array and one verdict array per column, the cell table, the arena. The core
|
|
10
|
+
# keeps none of it between calls beyond what it writes into the state block.
|
|
11
|
+
class Delimited
|
|
12
|
+
# The call did what it could; the result says how far it got.
|
|
13
|
+
OK = 0
|
|
14
|
+
# A caller bug, never a data verdict.
|
|
15
|
+
ERR_CONTRACT = -1
|
|
16
|
+
# The data is structurally broken; the failure says where.
|
|
17
|
+
ERR_STRUCTURE = -2
|
|
18
|
+
# The arena cannot hold what one row needs.
|
|
19
|
+
ERR_ARENA = -3
|
|
20
|
+
# The cell table (or the header's name table) cannot hold one row.
|
|
21
|
+
ERR_CELLS = -4
|
|
22
|
+
|
|
23
|
+
# The flag in the top bit of a span's length. On a cell-table entry: the cell has ""
|
|
24
|
+
# inside and has to be unescaped to be read. On a text value or a header name: the
|
|
25
|
+
# bytes are in the arena rather than in the input.
|
|
26
|
+
SPAN_FLAG = 1 << 31
|
|
27
|
+
# A span's length without its flag.
|
|
28
|
+
SPAN_LENGTH = SPAN_FLAG - 1
|
|
29
|
+
|
|
30
|
+
# Bytes in one span.
|
|
31
|
+
SPAN_BYTES = 8
|
|
32
|
+
# Filled: rows, consumed, arena_used, needed, then Failure (code, line, record, byte,
|
|
33
|
+
# expected, found).
|
|
34
|
+
FILLED_BYTES = 64
|
|
35
|
+
FILLED = "Q<4L<2Q<2L<2".freeze
|
|
36
|
+
# Buffers: seven pointer and size pairs (window, arena, cells, row, strings, table,
|
|
37
|
+
# kinds), as every workbook call takes them. A delimited fill reads the arena and the
|
|
38
|
+
# cell table from it and nothing else.
|
|
39
|
+
BUFFERS = "Q<14".freeze
|
|
40
|
+
BUFFERS_BYTES = 112
|
|
41
|
+
|
|
42
|
+
ARENA_BYTES = 4096
|
|
43
|
+
NAMES = 64
|
|
44
|
+
|
|
45
|
+
CONTRACT = "hypertabular: libhypertabular reported a contract violation — a binding bug, " \
|
|
46
|
+
"please report it".freeze
|
|
47
|
+
|
|
48
|
+
# What the last call wrote: rows (or header names), input bytes finished with, arena
|
|
49
|
+
# bytes written, and — after ERR_STRUCTURE — the failure as
|
|
50
|
+
# [code, line, record, byte, expected, found].
|
|
51
|
+
attr_reader :rows, :consumed, :arena_used, :failure
|
|
52
|
+
|
|
53
|
+
# The plan's arrays (Columns), and the cell-table entries one row takes: the widest
|
|
54
|
+
# ordinal the plan reads, plus two — or more, if the core asked for more.
|
|
55
|
+
attr_reader :columns, :per_row
|
|
56
|
+
|
|
57
|
+
# The loaded core's version word, major << 16 | minor << 8 | patch.
|
|
58
|
+
def self.version
|
|
59
|
+
Runtime.function(:hypertabular_version).call
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# +dialect+ is the four bytes of a RawDialect; +specs+ one packed ColumnSpec per plan
|
|
63
|
+
# column and +sizes+ the bytes one value of each takes; +per_row+ the cell-table
|
|
64
|
+
# entries one row takes. Nil when the core refuses the dialect.
|
|
65
|
+
def self.start(dialect, specs, sizes, batch_rows, per_row)
|
|
66
|
+
reader = new(specs, sizes, batch_rows, per_row)
|
|
67
|
+
reader.send(:init, dialect) ? reader : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(specs, sizes, batch_rows, per_row)
|
|
71
|
+
@header = Runtime.function(:hypertabular_delimited_header)
|
|
72
|
+
@fill = Runtime.function(:hypertabular_delimited_fill)
|
|
73
|
+
@state = Runtime.buffer(Runtime.function(:hypertabular_delimited_state_size).call)
|
|
74
|
+
@batch_rows = batch_rows
|
|
75
|
+
@columns = Columns.new(specs, sizes, batch_rows)
|
|
76
|
+
@per_row = per_row
|
|
77
|
+
@cramped = false
|
|
78
|
+
@cells_cap = per_row * batch_rows
|
|
79
|
+
@cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
|
|
80
|
+
@arena_cap = ARENA_BYTES
|
|
81
|
+
@arena = Runtime.buffer(@arena_cap)
|
|
82
|
+
@out = Runtime.buffer(FILLED_BYTES)
|
|
83
|
+
@buffers = Runtime.buffer(BUFFERS_BYTES)
|
|
84
|
+
@rows = @consumed = @arena_used = 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Names the String the calls that follow read: +start+ and +offset+ below index its
|
|
88
|
+
# bytes. It is held where it is, not copied, and must not be modified until another
|
|
89
|
+
# one is attached.
|
|
90
|
+
def attach(input)
|
|
91
|
+
@pin = Runtime.pin(input)
|
|
92
|
+
@base = @pin.to_i
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Reads the next record of the attached input, +length+ bytes from +start+, as a
|
|
96
|
+
# header. OK or ERR_STRUCTURE; +rows+ is then the number of names.
|
|
97
|
+
def header(start, length, last)
|
|
98
|
+
@names_cap ||= NAMES
|
|
99
|
+
@names ||= Runtime.buffer(SPAN_BYTES * @names_cap)
|
|
100
|
+
loop do
|
|
101
|
+
code = @header.call(@state, @base + start, length, last ? 1 : 0,
|
|
102
|
+
@names, @names_cap, @arena, @arena_cap, @out)
|
|
103
|
+
needed = finished
|
|
104
|
+
case code
|
|
105
|
+
when OK, ERR_STRUCTURE then return code
|
|
106
|
+
when ERR_CELLS
|
|
107
|
+
@names_cap = needed
|
|
108
|
+
@names = Runtime.buffer(SPAN_BYTES * @names_cap)
|
|
109
|
+
when ERR_ARENA then grow_arena(needed)
|
|
110
|
+
else raise CONTRACT
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The header's names as the core located them: offset and flagged length, a pair per
|
|
116
|
+
# name, offsets relative to the +start+ the header was read at (or into the arena,
|
|
117
|
+
# for a flagged one).
|
|
118
|
+
def names
|
|
119
|
+
@names[0, SPAN_BYTES * @rows].unpack("L<*")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Fills every column from the attached input, +length+ bytes from +start+ — the one
|
|
123
|
+
# native call a batch makes. OK or ERR_STRUCTURE.
|
|
124
|
+
#
|
|
125
|
+
# The core ends a batch early when the arena fills, so a batch that came back short
|
|
126
|
+
# with the arena half used or more has the next one start with it doubled: escaped
|
|
127
|
+
# text costs a few batches, not one per row.
|
|
128
|
+
def fill(start, length, last)
|
|
129
|
+
grow_arena(@arena_cap * 2) if @cramped
|
|
130
|
+
@cramped = false
|
|
131
|
+
loop do
|
|
132
|
+
code = @fill.call(@state, @base + start, length, last ? 1 : 0,
|
|
133
|
+
@columns.specs, @columns.table, @columns.count, @batch_rows,
|
|
134
|
+
buffers, @out)
|
|
135
|
+
needed = finished
|
|
136
|
+
case code
|
|
137
|
+
when OK
|
|
138
|
+
@cramped = @rows.positive? && @rows < @batch_rows && @arena_used * 2 >= @arena_cap
|
|
139
|
+
return code
|
|
140
|
+
when ERR_STRUCTURE then return code
|
|
141
|
+
when ERR_CELLS
|
|
142
|
+
@per_row = [@per_row, needed].max
|
|
143
|
+
@cells_cap = @per_row * @batch_rows
|
|
144
|
+
@cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
|
|
145
|
+
when ERR_ARENA then grow_arena(needed)
|
|
146
|
+
else raise CONTRACT
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The cell table for the batch in hand — #per_row spans a row — copied out.
|
|
152
|
+
def cells
|
|
153
|
+
@cells[0, SPAN_BYTES * @per_row * @rows]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The arena as the last call left it: the unescaped text of every flagged span.
|
|
157
|
+
def arena
|
|
158
|
+
@arena[0, @arena_used]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Records finished so far — the header and skipped blank lines included.
|
|
162
|
+
def records
|
|
163
|
+
@state[8, 8].unpack1("Q<")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# One-based line number of the next unread byte.
|
|
167
|
+
def line
|
|
168
|
+
@state[4, 4].unpack1("L<")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Absolute byte offset of the next unread byte.
|
|
172
|
+
def offset
|
|
173
|
+
@state[16, 8].unpack1("Q<")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def init(dialect)
|
|
179
|
+
raw = Runtime.buffer(4)
|
|
180
|
+
raw[0, 4] = dialect
|
|
181
|
+
Runtime.function(:hypertabular_delimited_init).call(@state, raw) == OK
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The arena and the cell table as the Buffers block a fill takes them — packed again
|
|
185
|
+
# for every call, since either may have been grown since the last.
|
|
186
|
+
def buffers
|
|
187
|
+
@buffers[0, BUFFERS_BYTES] = [0, 0, @arena.to_i, @arena_cap, @cells.to_i, @cells_cap, 0, 0, 0, 0, 0, 0, 0, 0]
|
|
188
|
+
.pack(BUFFERS)
|
|
189
|
+
@buffers
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Reads what the call wrote to its Filled block; returns `needed`.
|
|
193
|
+
def finished
|
|
194
|
+
@rows, @consumed, @arena_used, needed, *@failure = @out[0, FILLED_BYTES].unpack(FILLED)
|
|
195
|
+
needed
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def grow_arena(needed)
|
|
199
|
+
@arena_cap = [needed, @arena_cap * 2].max
|
|
200
|
+
@arena = Runtime.buffer(@arena_cap)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -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
|