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.
@@ -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
@@ -1,4 +1,4 @@
1
1
  module Rbxl
2
2
  # Gem version string, tracked with semantic versioning.
3
- VERSION = "1.3.0"
3
+ VERSION = "1.5.0"
4
4
  end
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 two explicit, non-overlapping modes:
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 in read-only row-by-row mode.
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+ raises
93
- # {NotImplementedError}; a read/write mode is not available.
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 +true+
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] opened workbook; auto-closed
129
- # when the block returns
130
- # @return [Rbxl::ReadOnlyWorkbook, Object] the workbook when no block is
131
- # given, otherwise the block's return value
132
- # @raise [NotImplementedError] if +read_only+ is not +true+
133
- def open(path, read_only: true, streaming: false, date_conversion: false, &block)
134
- raise NotImplementedError, "read/write mode is not supported; pass read_only: true" unless read_only
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
- def self.open: (pathish path, ?read_only: bool, ?streaming: bool, ?date_conversion: bool) -> ReadOnlyWorkbook
13
- | [T] (pathish path, ?read_only: bool, ?streaming: bool, ?date_conversion: bool) { (ReadOnlyWorkbook) -> T } -> T
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.3.0
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: '2.3'
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: '2.3'
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.1'
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: 4.0.3
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: []