rbxl 1.3.0 → 1.5.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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +104 -12
- data/lib/rbxl/editable_cell.rb +176 -0
- data/lib/rbxl/editable_workbook.rb +315 -0
- data/lib/rbxl/editable_worksheet.rb +216 -0
- data/lib/rbxl/errors.rb +6 -0
- data/lib/rbxl/read_only_workbook.rb +1 -82
- data/lib/rbxl/shared_strings_loader.rb +100 -0
- data/lib/rbxl/version.rb +1 -1
- data/lib/rbxl.rb +52 -12
- data/sig/rbxl.rbs +63 -2
- metadata +17 -7
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Rbxl
|
|
2
|
+
# Streams +xl/sharedStrings.xml+ out of an opened +.xlsx+ ZIP and decodes
|
|
3
|
+
# the table to an immutable +Array<String>+.
|
|
4
|
+
#
|
|
5
|
+
# Both the read-only and edit modes need this same view of the SST. The
|
|
6
|
+
# logic is identical — phonetic guides are skipped, +<r>+/+<t>+ runs inside
|
|
7
|
+
# an +<si>+ are concatenated, the count and byte caps configured on
|
|
8
|
+
# {Rbxl} are enforced — so it lives here as a single source of truth
|
|
9
|
+
# rather than being inlined twice.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module SharedStringsLoader
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param zip [Zip::File] the open package
|
|
16
|
+
# @return [Array<String>] frozen, index-aligned shared strings table
|
|
17
|
+
# @raise [Rbxl::SharedStringsTooLargeError] if the table exceeds the
|
|
18
|
+
# configured count or byte limits
|
|
19
|
+
def load(zip)
|
|
20
|
+
entry = zip.find_entry("xl/sharedStrings.xml")
|
|
21
|
+
return [].freeze unless entry
|
|
22
|
+
|
|
23
|
+
max_count = Rbxl.max_shared_strings
|
|
24
|
+
max_bytes = Rbxl.max_shared_string_bytes
|
|
25
|
+
|
|
26
|
+
# Reject zip-bomb style entries up front using the ZIP directory's
|
|
27
|
+
# declared uncompressed size, before allocating any decompression buffer.
|
|
28
|
+
if max_bytes && entry.size && entry.size > max_bytes
|
|
29
|
+
raise SharedStringsTooLargeError,
|
|
30
|
+
"shared strings uncompressed size #{entry.size} exceeds limit #{max_bytes}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
strings = []
|
|
34
|
+
total_bytes = 0
|
|
35
|
+
io = entry.get_input_stream
|
|
36
|
+
reader = Nokogiri::XML::Reader(io)
|
|
37
|
+
|
|
38
|
+
in_si = false
|
|
39
|
+
in_run = false
|
|
40
|
+
in_phonetic = false
|
|
41
|
+
collecting_text = false
|
|
42
|
+
buffer = +""
|
|
43
|
+
current_fragments = []
|
|
44
|
+
|
|
45
|
+
reader.each do |node|
|
|
46
|
+
case node.node_type
|
|
47
|
+
when Nokogiri::XML::Reader::TYPE_ELEMENT
|
|
48
|
+
case node.local_name
|
|
49
|
+
when "si"
|
|
50
|
+
in_si = true
|
|
51
|
+
current_fragments = []
|
|
52
|
+
when "r"
|
|
53
|
+
in_run = true if in_si
|
|
54
|
+
when "rPh"
|
|
55
|
+
in_phonetic = true if in_si
|
|
56
|
+
when "t"
|
|
57
|
+
next unless in_si && !in_phonetic
|
|
58
|
+
|
|
59
|
+
collecting_text = !in_run || node.depth.positive?
|
|
60
|
+
buffer.clear if collecting_text
|
|
61
|
+
end
|
|
62
|
+
when Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_CDATA
|
|
63
|
+
buffer << node.value if collecting_text
|
|
64
|
+
when Nokogiri::XML::Reader::TYPE_END_ELEMENT
|
|
65
|
+
case node.local_name
|
|
66
|
+
when "t"
|
|
67
|
+
if collecting_text
|
|
68
|
+
current_fragments << buffer.dup
|
|
69
|
+
collecting_text = false
|
|
70
|
+
end
|
|
71
|
+
when "r"
|
|
72
|
+
in_run = false
|
|
73
|
+
when "rPh"
|
|
74
|
+
in_phonetic = false
|
|
75
|
+
when "si"
|
|
76
|
+
value = current_fragments.join.freeze
|
|
77
|
+
total_bytes += value.bytesize
|
|
78
|
+
if max_bytes && total_bytes > max_bytes
|
|
79
|
+
raise SharedStringsTooLargeError,
|
|
80
|
+
"shared strings total size exceeds limit #{max_bytes}"
|
|
81
|
+
end
|
|
82
|
+
strings << value
|
|
83
|
+
if max_count && strings.size > max_count
|
|
84
|
+
raise SharedStringsTooLargeError,
|
|
85
|
+
"shared strings count exceeds limit #{max_count}"
|
|
86
|
+
end
|
|
87
|
+
in_si = false
|
|
88
|
+
in_run = false
|
|
89
|
+
in_phonetic = false
|
|
90
|
+
collecting_text = false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
strings.freeze
|
|
96
|
+
ensure
|
|
97
|
+
io&.close
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/rbxl/version.rb
CHANGED
data/lib/rbxl.rb
CHANGED
|
@@ -8,9 +8,13 @@ require "zip"
|
|
|
8
8
|
require_relative "rbxl/cell"
|
|
9
9
|
require_relative "rbxl/empty_cell"
|
|
10
10
|
require_relative "rbxl/errors"
|
|
11
|
+
require_relative "rbxl/shared_strings_loader"
|
|
11
12
|
require_relative "rbxl/read_only_cell"
|
|
12
13
|
require_relative "rbxl/read_only_workbook"
|
|
13
14
|
require_relative "rbxl/read_only_worksheet"
|
|
15
|
+
require_relative "rbxl/editable_cell"
|
|
16
|
+
require_relative "rbxl/editable_worksheet"
|
|
17
|
+
require_relative "rbxl/editable_workbook"
|
|
14
18
|
require_relative "rbxl/row"
|
|
15
19
|
require_relative "rbxl/version"
|
|
16
20
|
require_relative "rbxl/write_only_cell"
|
|
@@ -19,9 +23,13 @@ require_relative "rbxl/write_only_worksheet"
|
|
|
19
23
|
|
|
20
24
|
# Minimal, memory-friendly XLSX reader/writer inspired by +openpyxl+.
|
|
21
25
|
#
|
|
22
|
-
# Rbxl exposes
|
|
26
|
+
# Rbxl exposes three explicit, non-overlapping modes, each picked up by
|
|
27
|
+
# {Rbxl.open} / {Rbxl.new}:
|
|
23
28
|
#
|
|
24
29
|
# * {Rbxl.open} returns a {Rbxl::ReadOnlyWorkbook} for row-by-row reads
|
|
30
|
+
# * {Rbxl.open} with <tt>edit: true</tt> returns a {Rbxl::EditableWorkbook}
|
|
31
|
+
# for surgical read-modify-save passes that round-trip every untouched
|
|
32
|
+
# part byte-for-byte
|
|
25
33
|
# * {Rbxl.new} returns a {Rbxl::WriteOnlyWorkbook} for one-shot writes
|
|
26
34
|
#
|
|
27
35
|
# The API is intentionally narrow so that memory usage stays predictable
|
|
@@ -86,11 +94,19 @@ module Rbxl
|
|
|
86
94
|
# @return [Integer, nil] per-worksheet streaming byte cap
|
|
87
95
|
attr_accessor :max_worksheet_bytes
|
|
88
96
|
|
|
89
|
-
# Opens an existing workbook
|
|
97
|
+
# Opens an existing workbook.
|
|
98
|
+
#
|
|
99
|
+
# By default opens in read-only row-by-row mode and returns a
|
|
100
|
+
# {Rbxl::ReadOnlyWorkbook}. Pass <tt>edit: true</tt> to open in
|
|
101
|
+
# read-modify-save mode and receive a {Rbxl::EditableWorkbook} instead.
|
|
102
|
+
# The two modes are wired up here at the module level so call sites pick
|
|
103
|
+
# a mode by keyword without juggling backend classes directly.
|
|
90
104
|
#
|
|
91
105
|
# The +read_only+ keyword defaults to +true+ and exists to mark the
|
|
92
|
-
# intent explicitly at the call site. Passing +read_only: false+
|
|
93
|
-
# {NotImplementedError}
|
|
106
|
+
# intent explicitly at the call site. Passing +read_only: false+ without
|
|
107
|
+
# also passing +edit: true+ raises {NotImplementedError} — there is no
|
|
108
|
+
# promiscuous read/write mode that mixes streaming reads with surgical
|
|
109
|
+
# writes.
|
|
94
110
|
#
|
|
95
111
|
# When a block is given, the workbook is yielded and automatically
|
|
96
112
|
# closed when the block returns (or raises), mirroring the +File.open+
|
|
@@ -100,6 +116,11 @@ module Rbxl
|
|
|
100
116
|
# book.sheet("Report").each_row(values_only: true) { |row| p row }
|
|
101
117
|
# end
|
|
102
118
|
#
|
|
119
|
+
# Rbxl.open("template.xlsx", edit: true) do |book|
|
|
120
|
+
# book.sheet("Sheet1")["B5"].value = "Acme Inc."
|
|
121
|
+
# book.save
|
|
122
|
+
# end
|
|
123
|
+
#
|
|
103
124
|
# With <tt>streaming: true</tt>, the native backend (when loaded) feeds
|
|
104
125
|
# worksheet XML to the parser in chunks pulled from the ZIP input stream
|
|
105
126
|
# instead of materializing the entire worksheet as one Ruby string. This
|
|
@@ -118,20 +139,39 @@ module Rbxl
|
|
|
118
139
|
# disables the native fast path and routes reads through the Ruby
|
|
119
140
|
# worksheet parser.
|
|
120
141
|
#
|
|
142
|
+
# +streaming:+ and +date_conversion:+ are read-mode options and are
|
|
143
|
+
# rejected when paired with +edit: true+, since the editable backend
|
|
144
|
+
# does not run worksheets through the streaming parser.
|
|
145
|
+
#
|
|
121
146
|
# @param path [String, #to_path] filesystem path to an <tt>.xlsx</tt> file
|
|
122
|
-
# @param read_only [Boolean] retained for call-site clarity; must be
|
|
147
|
+
# @param read_only [Boolean] retained for call-site clarity; must be
|
|
148
|
+
# +true+ unless +edit: true+ is also passed
|
|
149
|
+
# @param edit [Boolean] open in read-modify-save mode; returns an
|
|
150
|
+
# {Rbxl::EditableWorkbook}
|
|
123
151
|
# @param streaming [Boolean] feed worksheet XML to the native parser in
|
|
124
152
|
# chunks instead of fully inflating the entry in advance. Ignored when
|
|
125
153
|
# the native extension is not loaded.
|
|
126
154
|
# @param date_conversion [Boolean] convert numeric cells backed by a
|
|
127
155
|
# date/time +numFmt+ to +Date+ / +Time+ / +DateTime+
|
|
128
|
-
# @yieldparam book [Rbxl::ReadOnlyWorkbook]
|
|
129
|
-
# when the block returns
|
|
130
|
-
# @return [Rbxl::ReadOnlyWorkbook, Object] the
|
|
131
|
-
# given, otherwise the block's return value
|
|
132
|
-
# @raise [NotImplementedError] if +read_only+ is
|
|
133
|
-
|
|
134
|
-
|
|
156
|
+
# @yieldparam book [Rbxl::ReadOnlyWorkbook, Rbxl::EditableWorkbook]
|
|
157
|
+
# opened workbook; auto-closed when the block returns
|
|
158
|
+
# @return [Rbxl::ReadOnlyWorkbook, Rbxl::EditableWorkbook, Object] the
|
|
159
|
+
# workbook when no block is given, otherwise the block's return value
|
|
160
|
+
# @raise [NotImplementedError] if +read_only+ is +false+ without
|
|
161
|
+
# +edit: true+
|
|
162
|
+
# @raise [ArgumentError] if +edit: true+ is paired with read-only options
|
|
163
|
+
def open(path, read_only: true, edit: false, streaming: false, date_conversion: false, &block)
|
|
164
|
+
if edit
|
|
165
|
+
if streaming || date_conversion
|
|
166
|
+
raise ArgumentError,
|
|
167
|
+
"edit: true is incompatible with streaming:/date_conversion:; " \
|
|
168
|
+
"those options apply to the read-only mode"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
return EditableWorkbook.open(path, &block)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
raise NotImplementedError, "read/write mode is not supported; pass read_only: true or edit: true" unless read_only
|
|
135
175
|
|
|
136
176
|
ReadOnlyWorkbook.open(path, streaming: streaming, date_conversion: date_conversion, &block)
|
|
137
177
|
end
|
data/sig/rbxl.rbs
CHANGED
|
@@ -9,8 +9,10 @@ module Rbxl
|
|
|
9
9
|
type row_cells = Array[row_cell]
|
|
10
10
|
type dimensions = { ref: String, max_col: Integer, max_row: Integer }
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
type editable_cell_value = String | Integer | Float | bool | nil
|
|
13
|
+
|
|
14
|
+
def self.open: (pathish path, ?read_only: bool, ?edit: bool, ?streaming: bool, ?date_conversion: bool) -> (ReadOnlyWorkbook | EditableWorkbook)
|
|
15
|
+
| [T] (pathish path, ?read_only: bool, ?edit: bool, ?streaming: bool, ?date_conversion: bool) { (ReadOnlyWorkbook | EditableWorkbook) -> T } -> T
|
|
14
16
|
def self.new: (?write_only: bool) -> WriteOnlyWorkbook
|
|
15
17
|
|
|
16
18
|
attr_accessor self.max_shared_strings: Integer?
|
|
@@ -41,6 +43,18 @@ module Rbxl
|
|
|
41
43
|
class UnsupportedFormatError < Error
|
|
42
44
|
end
|
|
43
45
|
|
|
46
|
+
class WorkbookFormatError < Error
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class WorksheetFormatError < Error
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class CellValueError < WorksheetFormatError
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class EditableCellTypeError < Error
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
class Cell
|
|
45
59
|
attr_accessor value: cell_value
|
|
46
60
|
attr_accessor coordinate: String?
|
|
@@ -132,4 +146,51 @@ module Rbxl
|
|
|
132
146
|
def append: (row_input values) -> WriteOnlyWorksheet
|
|
133
147
|
def to_xml: () -> String
|
|
134
148
|
end
|
|
149
|
+
|
|
150
|
+
class EditableWorkbook
|
|
151
|
+
MAIN_NS: String
|
|
152
|
+
REL_NS: String
|
|
153
|
+
PACKAGE_REL_NS: String
|
|
154
|
+
OFFICE_DOC_REL_TYPE: String
|
|
155
|
+
|
|
156
|
+
attr_reader path: String
|
|
157
|
+
attr_reader sheet_names: Array[String]
|
|
158
|
+
|
|
159
|
+
def self.open: (pathish path) -> EditableWorkbook
|
|
160
|
+
| [T] (pathish path) { (EditableWorkbook) -> T } -> T
|
|
161
|
+
def initialize: (pathish path) -> void
|
|
162
|
+
def sheet: (String | Integer name_or_index) -> EditableWorksheet
|
|
163
|
+
def sheets: () { (EditableWorksheet) -> void } -> void
|
|
164
|
+
| () -> Enumerator[EditableWorksheet, void]
|
|
165
|
+
def save: (?pathish? path) -> String
|
|
166
|
+
def close: () -> bool
|
|
167
|
+
def closed?: () -> bool
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
class EditableWorksheet
|
|
171
|
+
MAIN_NS: String
|
|
172
|
+
|
|
173
|
+
attr_reader name: String
|
|
174
|
+
attr_reader entry_path: String
|
|
175
|
+
|
|
176
|
+
def initialize: (zip: untyped, entry_path: String, workbook_path: String, shared_strings: Array[String], name: String) -> void
|
|
177
|
+
def cell: (String coordinate) -> EditableCell
|
|
178
|
+
def []: (String coordinate) -> EditableCell
|
|
179
|
+
def dirty?: () -> bool
|
|
180
|
+
def to_xml: () -> String
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
class EditableCell
|
|
184
|
+
MAIN_NS: String
|
|
185
|
+
|
|
186
|
+
attr_reader coordinate: String
|
|
187
|
+
|
|
188
|
+
def initialize: (worksheet: EditableWorksheet, coordinate: String) -> void
|
|
189
|
+
def value: () -> editable_cell_value
|
|
190
|
+
def value=: (editable_cell_value new_value) -> editable_cell_value
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
module SharedStringsLoader
|
|
194
|
+
def self.load: (untyped zip) -> Array[String]
|
|
195
|
+
end
|
|
135
196
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rbxl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Taro KOBAYASHI
|
|
@@ -13,16 +13,22 @@ dependencies:
|
|
|
13
13
|
name: rubyzip
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.4'
|
|
19
|
+
- - "<"
|
|
17
20
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
21
|
+
version: '4'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
|
-
- - "
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.4'
|
|
29
|
+
- - "<"
|
|
24
30
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
31
|
+
version: '4'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: nokogiri
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -60,6 +66,9 @@ files:
|
|
|
60
66
|
- ext/rbxl_native/native.c
|
|
61
67
|
- lib/rbxl.rb
|
|
62
68
|
- lib/rbxl/cell.rb
|
|
69
|
+
- lib/rbxl/editable_cell.rb
|
|
70
|
+
- lib/rbxl/editable_workbook.rb
|
|
71
|
+
- lib/rbxl/editable_worksheet.rb
|
|
63
72
|
- lib/rbxl/empty_cell.rb
|
|
64
73
|
- lib/rbxl/errors.rb
|
|
65
74
|
- lib/rbxl/native.rb
|
|
@@ -67,6 +76,7 @@ files:
|
|
|
67
76
|
- lib/rbxl/read_only_workbook.rb
|
|
68
77
|
- lib/rbxl/read_only_worksheet.rb
|
|
69
78
|
- lib/rbxl/row.rb
|
|
79
|
+
- lib/rbxl/shared_strings_loader.rb
|
|
70
80
|
- lib/rbxl/version.rb
|
|
71
81
|
- lib/rbxl/write_only_cell.rb
|
|
72
82
|
- lib/rbxl/write_only_workbook.rb
|
|
@@ -87,14 +97,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
87
97
|
requirements:
|
|
88
98
|
- - ">="
|
|
89
99
|
- !ruby/object:Gem::Version
|
|
90
|
-
version: '3.
|
|
100
|
+
version: '3.2'
|
|
91
101
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
102
|
requirements:
|
|
93
103
|
- - ">="
|
|
94
104
|
- !ruby/object:Gem::Version
|
|
95
105
|
version: '0'
|
|
96
106
|
requirements: []
|
|
97
|
-
rubygems_version:
|
|
107
|
+
rubygems_version: 3.6.9
|
|
98
108
|
specification_version: 4
|
|
99
109
|
summary: Fast, low-memory XLSX processing for Ruby.
|
|
100
110
|
test_files: []
|