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.
@@ -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
@@ -0,0 +1,173 @@
1
+ module HyperTabular
2
+ # One sheet of a workbook, as Workbook#sheets lists it: its name, and whether the workbook
3
+ # hides it. A hidden sheet reads like any other.
4
+ SheetInfo = Data.define(:name, :hidden)
5
+
6
+ # How a sheet is read: whether its first row is a header (exposed through Sheet#header
7
+ # and never delivered as a row), whether a row with no cells is skipped, and the most rows
8
+ # a batch holds.
9
+ SheetOptions = Data.define(:has_header, :skip_empty_rows, :batch_rows) do
10
+ # Every option has a default; ArgumentError unless +batch_rows+ is a positive Integer.
11
+ def initialize(has_header: true, skip_empty_rows: true, batch_rows: DelimitedReader::DEFAULT_BATCH_ROWS)
12
+ raise ArgumentError, "batch_rows must be a positive Integer; got #{batch_rows.inspect}" unless
13
+ batch_rows.is_a?(Integer) && batch_rows.positive?
14
+
15
+ super
16
+ end
17
+ end
18
+
19
+ # A header, empty rows skipped, DelimitedReader::DEFAULT_BATCH_ROWS rows a batch.
20
+ SheetOptions::DEFAULT = SheetOptions.new
21
+
22
+ # An XLSX or ODS workbook held in memory, its sheets listed and its shared strings and
23
+ # styles loaded: what a Sheet reads from.
24
+ #
25
+ # book = HyperTabular::Workbook.open("orders.xlsx")
26
+ # book.sheets # => [#<data HyperTabular::SheetInfo name="Orders", hidden=false>]
27
+ # sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT, plan)
28
+ # while (batch = sheet.read) ... end
29
+ #
30
+ # A workbook that cannot be read — not a zip, encrypted, a part missing or broken — is a
31
+ # TabularError from Workbook.new. Its sheets may be read at once, each with buffers of its
32
+ # own. Not thread-safe.
33
+ class Workbook
34
+ # Reads the file at +path+ into memory and opens it.
35
+ def self.open(path)
36
+ new(File.binread(path))
37
+ end
38
+
39
+ # Opens the workbook in +bytes+, a String, which is read in place: a frozen String is
40
+ # the workbook's as it is, anything else is copied once.
41
+ def initialize(bytes)
42
+ raise ArgumentError, "bytes must be a String; got #{bytes.class}" unless bytes.is_a?(String)
43
+
44
+ bytes = bytes.b.freeze unless bytes.frozen?
45
+ @book = Runtime::Book::Opened.new(bytes)
46
+ @sheets = @book.sheets.map { |name, hidden, _part, _index| SheetInfo.new(name: name, hidden: hidden) }.freeze
47
+ rescue Runtime::Book::StructureError => e
48
+ raise TabularError.from(e.failure)
49
+ end
50
+
51
+ # The workbook's sheets, in its own order, as SheetInfo. Sheets that hold no cells
52
+ # (chart sheets, macro sheets) are not among them.
53
+ attr_reader :sheets
54
+
55
+ # Which kind of workbook this is: :xlsx or :ods.
56
+ def format
57
+ @book.format
58
+ end
59
+
60
+ # The date system the workbook's serials count in — what a date-formatted number is
61
+ # read by: :y1900 or :y1904, as HyperCast names them.
62
+ def date_system
63
+ HyperCast::EXCEL_EPOCHS.key(@book.epoch)
64
+ end
65
+
66
+ # Starts a read of one sheet — +which+ is its index in #sheets or its name — through
67
+ # +plan+, as +options+ (SheetOptions) says. With a header declared the header row is
68
+ # read here. IndexError for an index the workbook has no sheet at, KeyError for a name
69
+ # it has no sheet by.
70
+ def sheet(which, options, plan)
71
+ index =
72
+ case which
73
+ when Integer
74
+ raise IndexError, "the workbook has #{@sheets.size} sheets, and no sheet #{which}" unless
75
+ which >= 0 && which < @sheets.size
76
+
77
+ which
78
+ when String
79
+ found = @sheets.index { |sheet| sheet.name == which }
80
+ raise KeyError, "the workbook has no sheet named #{which.inspect}" if found.nil?
81
+
82
+ found
83
+ else raise ArgumentError, "which must be an Integer or a String; got #{which.inspect}"
84
+ end
85
+ Sheet.new(@book, @book.sheets[index], options, plan)
86
+ end
87
+
88
+ # The workbook in a line.
89
+ def inspect
90
+ "#<#{self.class.name} #{format} sheets=#{@sheets.size}>"
91
+ end
92
+ end
93
+
94
+ # A forward-only read of one sheet of a Workbook, a batch at a time, through a plan — into
95
+ # the same Batch delimited text is read into.
96
+ #
97
+ # A typed cell is converted directly by its door — a stored 42.0 never passes through text
98
+ # to become an Integer — and a text cell goes through the door as delimited text would. A
99
+ # sheet that is structurally broken raises a TabularError after every intact row before
100
+ # the break has been delivered, and the same error again on every later read.
101
+ class Sheet
102
+ # The SheetOptions the sheet is read with.
103
+ attr_reader :options
104
+
105
+ # The plan: the output Columns, in output order.
106
+ attr_reader :plan
107
+
108
+ # The header row's names — a typed cell said the way the text door says it — as frozen
109
+ # UTF-8 Strings, or nil when the options declare no header.
110
+ attr_reader :header
111
+
112
+ # Made by Workbook#sheet.
113
+ def initialize(book, sheet, options, plan)
114
+ raise ArgumentError, "options must be a HyperTabular::SheetOptions; got #{options.inspect}" unless
115
+ options.is_a?(SheetOptions)
116
+
117
+ @plan = Array(plan).dup.freeze
118
+ @plan.each_with_index do |column, index|
119
+ raise ArgumentError, "plan column #{index} must be a HyperTabular::Column; got #{column.inspect}" unless
120
+ column.is_a?(Column)
121
+ end
122
+ @book = book
123
+ @options = options
124
+ width = (@plan.map(&:ordinal).max || -1) + 1
125
+ @reading = Runtime::Book::Reading.new(book, sheet, options.has_header, options.skip_empty_rows,
126
+ @plan.map(&:packed), @plan.map(&:value_bytes), options.batch_rows, width)
127
+ @header = @reading.header
128
+ @pending = nil
129
+ @failure = nil
130
+ rescue Runtime::Book::StructureError => e
131
+ raise TabularError.from(e.failure)
132
+ end
133
+
134
+ # Reads the next batch: a Batch of up to the options' batch_rows rows, or nil once the
135
+ # sheet has no more.
136
+ def read
137
+ if @pending
138
+ @failure = @pending
139
+ @pending = nil
140
+ end
141
+ raise @failure if @failure
142
+
143
+ rows, cells, arena, failure = @reading.fill
144
+ if failure
145
+ broken = TabularError.from(failure)
146
+ raise @failure = broken if rows.zero?
147
+
148
+ @pending = broken
149
+ end
150
+ return nil if rows.zero?
151
+
152
+ columns = @reading.columns
153
+ values = Array.new(@plan.size) { |column| columns.values(column, rows) }
154
+ Batch.new(@plan, rows, values, Array.new(@plan.size) { |column| columns.verdicts(column, rows) },
155
+ cells, @reading.per_row, @book.strings, 0, arena, workbook: true)
156
+ end
157
+
158
+ # Every remaining batch. Without a block, an Enumerator.
159
+ def each_batch
160
+ return to_enum(:each_batch) unless block_given?
161
+
162
+ while (batch = read)
163
+ yield batch
164
+ end
165
+ self
166
+ end
167
+
168
+ # The sheet in a line.
169
+ def inspect
170
+ "#<#{self.class.name} columns=#{@plan.size}>"
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,103 @@
1
+ require "date"
2
+ require "hypercast"
3
+ require_relative "hypertabular/runtime"
4
+ require_relative "hypertabular/runtime/columns"
5
+ require_relative "hypertabular/runtime/delimited"
6
+ require_relative "hypertabular/runtime/workbook"
7
+
8
+ # Tabular parsing with a HyperCast verdict for every cell: delimited text — CSV, TSV, any
9
+ # single-byte ASCII separator — and workbooks — XLSX and ODS — read a batch at a time into
10
+ # typed columns by one Rust core (libhypertabular), called through Fiddle.
11
+ #
12
+ # plan = [HyperTabular::Column.i32(0), HyperTabular::Column.text(1)]
13
+ # reader = HyperTabular::DelimitedReader.new("id,name\n1,alice\nx,bob\n", HyperTabular::Dialect::CSV, plan)
14
+ # reader.header # => ["id", "name"]
15
+ # batch = reader.read # => #<HyperTabular::Batch rows=2 columns=2>
16
+ # batch.values(1) # => ["alice", "bob"]
17
+ # batch.get(0, 1) # => #<data HyperCast::Fault reason=:malformed, offset=0, length=1>
18
+ # batch.raw(0, 1) # => "x"
19
+ # batch.line(1) # => 3
20
+ #
21
+ # book = HyperTabular::Workbook.open("orders.xlsx")
22
+ # sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT, plan)
23
+ # sheet.read # => the same Batch
24
+ #
25
+ # Nothing is sniffed: the Dialect states the separator, the quoting and the header,
26
+ # SheetOptions a sheet's header and whether empty rows are skipped, and the plan states each
27
+ # Column's door and, for numbers, its notation. HyperCast is the judge:
28
+ # the verdicts (HyperCast::Success, HyperCast::Fault), the notation (HyperCast::NumFormat),
29
+ # the exact decimal (HyperCast::Decimal) and the declared options are that gem's own types,
30
+ # and a cell means exactly what HyperCast's door would say of the same text. A bad value is
31
+ # a verdict; a broken file is a TabularError.
32
+ module HyperTabular
33
+ # This gem's own version — kept in lockstep with hypertabular.gemspec and the core's
34
+ # rust/Cargo.toml.
35
+ VERSION = "0.7.0"
36
+
37
+ class << self
38
+ # Whether the native core loaded and exports the ABI this binding was built against.
39
+ # Probed once (a native_version round trip: the cheapest call the core has), cached,
40
+ # and never raises: a missing shared library and an unsupported platform both answer
41
+ # false. A reader keeps its own behavior — building one without the core raises the
42
+ # precise LoadError — this only answers the question quietly.
43
+ def available?
44
+ return @available unless @available.nil?
45
+
46
+ @available = begin
47
+ native_version.is_a?(String)
48
+ rescue LoadError, StandardError
49
+ false
50
+ end
51
+ end
52
+
53
+ # The version of the native core actually loaded, as "major.minor.patch" — read from
54
+ # the library itself, not from this gem, so a consumer can prove the core behind the
55
+ # reader is the one this binding was built against.
56
+ def native_version
57
+ HyperCast::Interop.version(Runtime::Delimited.version)
58
+ end
59
+ end
60
+ end
61
+
62
+ require_relative "hypertabular/dialect"
63
+ require_relative "hypertabular/column"
64
+ require_relative "hypertabular/tabular_error"
65
+ require_relative "hypertabular/batch"
66
+ require_relative "hypertabular/delimited_reader"
67
+ require_relative "hypertabular/workbook"
68
+
69
+ # --- backend selection, as hypercast makes its own: the Magnus extension, when present,
70
+ # replaces the Fiddle crossing in place. Every native call and every byte of native memory is
71
+ # behind HyperTabular::Runtime (Runtime::Delimited, Runtime::Book::Opened and ::Reading, and
72
+ # Runtime.unescape), so that is all the extension redefines (rust/src/ruby_ext.rs): the
73
+ # constructors hand back objects of its own that answer the same methods with the same bytes,
74
+ # and everything above them — the readers, the workbook, Batch and every value and verdict it
75
+ # builds — is the same Ruby on either backend. It is how the precompiled platform gems ship,
76
+ # carrying no Fiddle library at all, and how the hypertabular-wasm gem runs in ruby.wasm,
77
+ # where there is no Fiddle to call. The Fiddle definitions stay the universal zero-compile
78
+ # fallback, for a Ruby or a platform no platform gem covers.
79
+ #
80
+ # HYPERTABULAR_PURE forces Fiddle, as HYPERCAST_PURE does for hypercast. It is a testing and
81
+ # diagnostic switch — CI runs the whole suite through it — read for presence, not value.
82
+ # Inside a platform gem there is no library for it to load: BACKEND still reads :fiddle,
83
+ # HyperTabular.available? answers false, and the first reader raises the LoadError naming the
84
+ # missing library.
85
+ HyperTabular::BACKEND =
86
+ if ENV["HYPERTABULAR_PURE"]
87
+ :fiddle
88
+ else
89
+ # A platform gem carries one extension per Ruby ABI under lib/hypertabular/<minor>/, and
90
+ # `rake native:dev` stages its own build there too; CI's in-job staging drops a single
91
+ # one flat at lib/. A miss on both is not an error: it is what the Fiddle backend is for.
92
+ begin
93
+ require "hypertabular/#{RUBY_VERSION[/\d+\.\d+/]}/hypertabular_native"
94
+ :native
95
+ rescue LoadError
96
+ begin
97
+ require "hypertabular_native"
98
+ :native
99
+ rescue LoadError
100
+ :fiddle
101
+ end
102
+ end
103
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hypertabular-wasm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.7.0
5
+ platform: ruby
6
+ authors:
7
+ - Brian Buvinghausen
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: hypercast-wasm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.7.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.7.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: yard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
54
+ description: |
55
+ The hypertabular gem's Magnus extension, prebuilt for wasm32-wasip1, for a ruby.wasm
56
+ interpreter built with `rbwasm build`: put this gem in that Gemfile instead of
57
+ hypertabular and the extension is linked into the interpreter, with hypercast-wasm beside
58
+ it. Same API and the same Rust core as the hypertabular gem. Not for a host Ruby, where
59
+ the hypertabular gem is the one to install.
60
+ executables: []
61
+ extensions:
62
+ - ext/hypertabular_native/extconf.rb
63
+ extra_rdoc_files: []
64
+ files:
65
+ - LICENSE
66
+ - README.md
67
+ - ext/hypertabular_native/3.4/hypertabular_native.a.gz
68
+ - ext/hypertabular_native/4.0/hypertabular_native.a.gz
69
+ - ext/hypertabular_native/extconf.rb
70
+ - lib/hypertabular.rb
71
+ - lib/hypertabular/batch.rb
72
+ - lib/hypertabular/column.rb
73
+ - lib/hypertabular/delimited_reader.rb
74
+ - lib/hypertabular/dialect.rb
75
+ - lib/hypertabular/runtime.rb
76
+ - lib/hypertabular/runtime/columns.rb
77
+ - lib/hypertabular/runtime/delimited.rb
78
+ - lib/hypertabular/runtime/workbook.rb
79
+ - lib/hypertabular/tabular_error.rb
80
+ - lib/hypertabular/workbook.rb
81
+ homepage: https://github.com/SkunkWerkx/HyperTabular
82
+ licenses:
83
+ - MIT
84
+ metadata:
85
+ source_code_uri: https://github.com/SkunkWerkx/HyperTabular
86
+ bug_tracker_uri: https://github.com/SkunkWerkx/HyperTabular/issues
87
+ changelog_uri: https://github.com/SkunkWerkx/HyperTabular/blob/master/CHANGELOG.md
88
+ documentation_uri: https://github.com/SkunkWerkx/HyperTabular/tree/master/ruby#ruby-in-the-browser
89
+ rubygems_mfa_required: 'true'
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '3.3'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 4.0.20
105
+ specification_version: 4
106
+ summary: 'HyperTabular for ruby.wasm: the Magnus extension, linked in by rbwasm build'
107
+ test_files: []