xlsxrb 0.1.12 → 0.1.13
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 +59 -42
- data/README.md +26 -22
- data/lib/xlsxrb/dsl_helpers.rb +59 -0
- data/lib/xlsxrb/elements/workbook.rb +11 -0
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +3 -5
- data/lib/xlsxrb/ooxml/workbook_writer.rb +5 -7
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +172 -9
- data/lib/xlsxrb/ooxml/writer.rb +2 -1
- data/lib/xlsxrb/ooxml/xml_builder.rb +20 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +364 -78
- data/lib/xlsxrb/ooxml/zip_writer.rb +32 -5
- data/lib/xlsxrb/stream_sheet.rb +94 -6
- data/lib/xlsxrb/stream_writer.rb +33 -66
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +1 -1
- data/lib/xlsxrb/worksheet_builder.rb +3 -31
- data/lib/xlsxrb.rb +448 -171
- data/sig/generated/xlsxrb/dsl_helpers.rbs +32 -0
- data/sig/generated/xlsxrb/elements/workbook.rbs +7 -0
- data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +0 -2
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +5 -4
- data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +11 -0
- data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +96 -8
- data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +5 -1
- data/sig/generated/xlsxrb/stream_sheet.rbs +43 -3
- data/sig/generated/xlsxrb.rbs +18 -11
- metadata +6 -146
- data/.devcontainer/Dockerfile +0 -65
- data/.devcontainer/devcontainer.json +0 -17
- data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/ast/2.4/ast.rbs +0 -73
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +0 -4
- data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +0 -16
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +0 -96
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +0 -4
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +0 -68
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +0 -249
- data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +0 -4
- data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +0 -47
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +0 -5
- data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/csv/3.3/csv.rbs +0 -3871
- data/.gem_rbs_collection/csv/3.3/manifest.yaml +0 -3
- data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +0 -48
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/listen/3.9/listen.rbs +0 -25
- data/.gem_rbs_collection/listen/3.9/listener.rbs +0 -24
- data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/logger/1.7/formatter.rbs +0 -45
- data/.gem_rbs_collection/logger/1.7/log_device.rbs +0 -100
- data/.gem_rbs_collection/logger/1.7/logger.rbs +0 -796
- data/.gem_rbs_collection/logger/1.7/manifest.yaml +0 -2
- data/.gem_rbs_collection/logger/1.7/period.rbs +0 -17
- data/.gem_rbs_collection/logger/1.7/severity.rbs +0 -34
- data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +0 -2332
- data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +0 -4
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +0 -86
- data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/parser/3.2/manifest.yaml +0 -7
- data/.gem_rbs_collection/parser/3.2/parser.rbs +0 -194
- data/.gem_rbs_collection/parser/3.2/polyfill.rbs +0 -4
- data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/rainbow/3.0/global.rbs +0 -7
- data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +0 -209
- data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +0 -5
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +0 -2
- data/.gem_rbs_collection/rake/13.0/rake.rbs +0 -39
- data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +0 -17
- data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +0 -208
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +0 -903
- data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +0 -9
- data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +0 -8
- data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +0 -42
- data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +0 -5
- data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +0 -47
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +0 -30
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +0 -9
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +0 -7
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +0 -19
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +0 -31
- data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +0 -18
- data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +0 -12
- data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +0 -11
- data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +0 -13
- data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +0 -95
- data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +0 -31
- data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +0 -58
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +0 -24
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +0 -17
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +0 -23
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +0 -22
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +0 -30
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +0 -20
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +0 -15
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +0 -26
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +0 -21
- data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +0 -131
- data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +0 -14
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +0 -33
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +0 -21
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +0 -63
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +0 -55
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +0 -35
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +0 -7
- data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +0 -10
- data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +0 -22
- data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +0 -29
- data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +0 -17
- data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +0 -13
- data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +0 -10
- data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +0 -8
- data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +0 -6
- data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +0 -30
- data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +0 -10
- data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +0 -10
- data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +0 -5
- data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +0 -15
- data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +0 -3
- data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +0 -40
- data/.mutant.yml +0 -8
- data/Rakefile +0 -633
- data/Steepfile +0 -18
- data/benchmark.rb +0 -406
- data/docs/ARCHITECTURE.md +0 -514
- data/docs/DEVELOPMENT.md +0 -137
- data/docs/MUTATION_TESTING.md +0 -52
- data/docs/PEER_LIBRARIES.md +0 -121
- data/docs/QUALITY_ASSURANCE.md +0 -26
- data/docs/SPEC_SOURCES.md +0 -48
- data/docs/assets/benchmark_results.svg +0 -144
- data/docs/assets/lsp_autocompletion.png +0 -0
- data/docs/assets/playground_preview.png +0 -0
- data/docs/coi-serviceworker.js +0 -82
- data/docs/office_thread.js +0 -77
- data/docs/preview.html +0 -719
- data/docs/visual/VisualGallery.md +0 -4683
- data/docs/wasm/ruby.wasm +0 -0
- data/docs/wasm/wasm_doc_helper.css +0 -351
- data/docs/wasm/wasm_doc_helper.js +0 -439
- data/docs/zeta.js +0 -1107
- data/rbs_collection.lock.yaml +0 -252
- data/rbs_collection.yaml +0 -19
- data/vendor/sdk_runner/Program.cs +0 -93
- data/vendor/sdk_runner/sdk_runner.csproj +0 -13
|
@@ -9,129 +9,356 @@ require "tempfile"
|
|
|
9
9
|
module Xlsxrb
|
|
10
10
|
module Ooxml
|
|
11
11
|
# Reads ZIP archives using only stdlib (zlib).
|
|
12
|
-
#
|
|
12
|
+
# Supports streaming entry decompression with bounded memory consumption.
|
|
13
13
|
class ZipReader
|
|
14
14
|
LOCAL_HEADER_SIG = "PK\x03\x04".b
|
|
15
15
|
MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024 # 500MB per file limit
|
|
16
16
|
attr_accessor :max_uncompressed_size
|
|
17
17
|
|
|
18
|
+
# Metadata record for a single archive entry.
|
|
19
|
+
class Entry
|
|
20
|
+
attr_reader :name, :method, :compressed_size, :uncompressed_size, :crc32, :local_header_offset, :data_offset
|
|
21
|
+
|
|
22
|
+
#: (name: String, method: Integer, compressed_size: Integer, uncompressed_size: Integer, crc32: Integer, local_header_offset: Integer, data_offset: Integer) -> void
|
|
23
|
+
def initialize(name:, method:, compressed_size:, uncompressed_size:, crc32:, local_header_offset:, data_offset:)
|
|
24
|
+
@name = name
|
|
25
|
+
@method = method
|
|
26
|
+
@compressed_size = compressed_size
|
|
27
|
+
@uncompressed_size = uncompressed_size
|
|
28
|
+
@crc32 = crc32
|
|
29
|
+
@local_header_offset = local_header_offset
|
|
30
|
+
@data_offset = data_offset
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# IO-like stream wrapper that decompresses entry chunks on demand without full buffering.
|
|
35
|
+
class EntryStreamIO
|
|
36
|
+
DEFAULT_CHUNK_SIZE = 65_536
|
|
37
|
+
|
|
38
|
+
attr_reader :data_offset, :compressed_size, :compression_method
|
|
39
|
+
|
|
40
|
+
#: (untyped io, Integer data_offset, Integer compressed_size, Integer compression_method, ?max_uncompressed_size: Integer?) -> void
|
|
41
|
+
def initialize(io, data_offset, compressed_size, compression_method, max_uncompressed_size: nil)
|
|
42
|
+
@io = io
|
|
43
|
+
@data_offset = data_offset
|
|
44
|
+
@compressed_size = compressed_size
|
|
45
|
+
@compression_method = compression_method
|
|
46
|
+
@max_uncompressed_size = max_uncompressed_size || ZipReader::MAX_UNCOMPRESSED_SIZE
|
|
47
|
+
@buffer = +""
|
|
48
|
+
@bytes_read_compressed = 0
|
|
49
|
+
@total_uncompressed = 0
|
|
50
|
+
@eof = false
|
|
51
|
+
@closed = false
|
|
52
|
+
@inflater = @compression_method == 8 ? Zlib::Inflate.new(-Zlib::MAX_WBITS) : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: (?Integer? length) -> String?
|
|
56
|
+
def read(length = nil)
|
|
57
|
+
raise IOError, "closed stream" if @closed
|
|
58
|
+
|
|
59
|
+
if length.nil?
|
|
60
|
+
result = @buffer.dup
|
|
61
|
+
@buffer.clear
|
|
62
|
+
while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
|
|
63
|
+
result << chunk
|
|
64
|
+
end
|
|
65
|
+
return result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return "" if length.zero?
|
|
69
|
+
|
|
70
|
+
while @buffer.bytesize < length && !@eof
|
|
71
|
+
chunk = fetch_chunk([length - @buffer.bytesize, DEFAULT_CHUNK_SIZE].max)
|
|
72
|
+
break unless chunk
|
|
73
|
+
|
|
74
|
+
@buffer << chunk
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return nil if @buffer.empty? && @eof
|
|
78
|
+
|
|
79
|
+
@buffer.slice!(0, length)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#: (?Integer chunk_size) ?{ (String) -> void } -> (void | Enumerator[String, void])
|
|
83
|
+
def each_chunk(chunk_size = DEFAULT_CHUNK_SIZE, &block)
|
|
84
|
+
return enum_for(:each_chunk, chunk_size) unless block
|
|
85
|
+
raise IOError, "closed stream" if @closed
|
|
86
|
+
|
|
87
|
+
while (chunk = read(chunk_size))
|
|
88
|
+
break if chunk.nil? || chunk.empty?
|
|
89
|
+
|
|
90
|
+
yield chunk
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
#: () -> void
|
|
95
|
+
def close
|
|
96
|
+
return if @closed
|
|
97
|
+
|
|
98
|
+
@closed = true
|
|
99
|
+
@inflater&.close
|
|
100
|
+
@inflater = nil
|
|
101
|
+
@buffer.clear
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
#: () -> bool
|
|
106
|
+
def closed?
|
|
107
|
+
@closed
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#: () -> bool
|
|
111
|
+
def eof?
|
|
112
|
+
@eof && @buffer.empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def fetch_chunk(chunk_size)
|
|
118
|
+
return nil if @eof || @closed
|
|
119
|
+
|
|
120
|
+
if @compression_method.zero?
|
|
121
|
+
remaining = @compressed_size - @bytes_read_compressed
|
|
122
|
+
if remaining <= 0
|
|
123
|
+
@eof = true
|
|
124
|
+
return nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
read_len = [remaining, chunk_size].min
|
|
128
|
+
@io.seek(@data_offset + @bytes_read_compressed, IO::SEEK_SET)
|
|
129
|
+
raw = @io.read(read_len)
|
|
130
|
+
if raw.nil? || raw.empty?
|
|
131
|
+
@eof = true
|
|
132
|
+
return nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@bytes_read_compressed += raw.bytesize
|
|
136
|
+
@total_uncompressed += raw.bytesize
|
|
137
|
+
raise ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{@max_uncompressed_size} bytes" if @total_uncompressed > @max_uncompressed_size
|
|
138
|
+
|
|
139
|
+
raw
|
|
140
|
+
elsif @compression_method == 8
|
|
141
|
+
inflated = +""
|
|
142
|
+
while inflated.empty? && !@eof
|
|
143
|
+
if @bytes_read_compressed < @compressed_size
|
|
144
|
+
remaining_compressed = @compressed_size - @bytes_read_compressed
|
|
145
|
+
read_len = [remaining_compressed, chunk_size].min
|
|
146
|
+
@io.seek(@data_offset + @bytes_read_compressed, IO::SEEK_SET)
|
|
147
|
+
raw = @io.read(read_len)
|
|
148
|
+
if raw.nil? || raw.empty?
|
|
149
|
+
@eof = true
|
|
150
|
+
else
|
|
151
|
+
@bytes_read_compressed += raw.bytesize
|
|
152
|
+
inflater = @inflater
|
|
153
|
+
inflated << inflater.inflate(raw) if inflater
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
inflater = @inflater
|
|
157
|
+
if inflater && !inflater.finished?
|
|
158
|
+
begin
|
|
159
|
+
inflated << inflater.finish
|
|
160
|
+
rescue Zlib::BufError, Zlib::DataError
|
|
161
|
+
# Stream ended
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
@eof = true
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
@eof = true if @inflater&.finished?
|
|
169
|
+
|
|
170
|
+
@total_uncompressed += inflated.bytesize
|
|
171
|
+
raise ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{@max_uncompressed_size} bytes" if @total_uncompressed > @max_uncompressed_size
|
|
172
|
+
|
|
173
|
+
inflated.empty? && @eof ? nil : inflated
|
|
174
|
+
else
|
|
175
|
+
@eof = true
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
18
181
|
# Opens a ZIP from a file path or IO and yields the reader.
|
|
19
182
|
#: (untyped source, ?max_uncompressed_size: Integer) ?{ (ZipReader) -> untyped } -> (ZipReader | untyped)
|
|
20
183
|
def self.open(source, max_uncompressed_size: MAX_UNCOMPRESSED_SIZE)
|
|
21
|
-
|
|
22
|
-
|
|
184
|
+
should_close = source.is_a?(String) && File.file?(source)
|
|
185
|
+
io = if source.is_a?(String)
|
|
186
|
+
if source.start_with?(LOCAL_HEADER_SIG) || source.include?("\x00")
|
|
187
|
+
StringIO.new(source.b)
|
|
188
|
+
else
|
|
189
|
+
File.open(source, "rb")
|
|
190
|
+
end
|
|
191
|
+
else
|
|
192
|
+
source
|
|
193
|
+
end
|
|
194
|
+
reader = new(io, max_uncompressed_size: max_uncompressed_size, should_close_io: should_close)
|
|
23
195
|
if block_given?
|
|
24
196
|
begin
|
|
25
197
|
yield reader
|
|
26
198
|
ensure
|
|
27
|
-
|
|
199
|
+
reader.close
|
|
28
200
|
end
|
|
29
201
|
else
|
|
30
202
|
reader
|
|
31
203
|
end
|
|
32
204
|
end
|
|
33
205
|
|
|
34
|
-
#: (untyped io, ?max_uncompressed_size: Integer) -> void
|
|
35
|
-
def initialize(io, max_uncompressed_size: MAX_UNCOMPRESSED_SIZE)
|
|
36
|
-
@
|
|
206
|
+
#: (untyped io, ?max_uncompressed_size: Integer, ?should_close_io: bool) -> void
|
|
207
|
+
def initialize(io, max_uncompressed_size: MAX_UNCOMPRESSED_SIZE, should_close_io: false)
|
|
208
|
+
@raw_io = io
|
|
37
209
|
@max_uncompressed_size = max_uncompressed_size
|
|
38
|
-
@
|
|
210
|
+
@should_close_io = should_close_io
|
|
211
|
+
@tempfile = nil
|
|
212
|
+
@io = nil
|
|
213
|
+
@catalog = nil
|
|
214
|
+
@cached_entries = nil
|
|
39
215
|
end
|
|
40
216
|
|
|
41
217
|
# Returns a Hash { entry_name => raw_bytes } for all entries.
|
|
218
|
+
#: () -> Hash[String, String?]
|
|
42
219
|
def read_all
|
|
43
220
|
result = {}
|
|
44
|
-
|
|
221
|
+
catalog.each_key do |name|
|
|
222
|
+
result[name] = read_entry(name)
|
|
223
|
+
end
|
|
45
224
|
result
|
|
46
225
|
end
|
|
47
226
|
|
|
48
227
|
# Returns raw bytes for a single entry, or nil if not found.
|
|
228
|
+
#: (String name) -> String?
|
|
49
229
|
def read_entry(name)
|
|
50
|
-
|
|
230
|
+
return @cached_entries[name] if @cached_entries&.key?(name)
|
|
231
|
+
|
|
232
|
+
entry = catalog[name]
|
|
233
|
+
return nil unless entry
|
|
234
|
+
|
|
235
|
+
io.seek(entry.data_offset, IO::SEEK_SET)
|
|
236
|
+
raw = io.read(entry.compressed_size)
|
|
237
|
+
decompress(raw, entry.method)
|
|
51
238
|
end
|
|
52
239
|
|
|
53
240
|
# Yields (entry_name, data_string) for each file in the archive.
|
|
241
|
+
#: () ?{ (String, String?) -> void } -> (void | Enumerator[[String, String?], void])
|
|
54
242
|
def each_entry(&block)
|
|
55
243
|
return enum_for(:each_entry) unless block
|
|
56
244
|
|
|
57
|
-
|
|
245
|
+
catalog.each_key do |name|
|
|
246
|
+
block.call(name, read_entry(name))
|
|
247
|
+
end
|
|
58
248
|
end
|
|
59
249
|
|
|
60
|
-
|
|
250
|
+
# Opens an EntryStreamIO for an entry by name.
|
|
251
|
+
#: (String entry_name) -> EntryStreamIO
|
|
252
|
+
def open_entry_io(entry_name)
|
|
253
|
+
entry = catalog[entry_name]
|
|
254
|
+
raise ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
|
|
61
255
|
|
|
62
|
-
|
|
63
|
-
@entries ||= parse_entries
|
|
256
|
+
EntryStreamIO.new(io, entry.data_offset, entry.compressed_size, entry.method, max_uncompressed_size: @max_uncompressed_size)
|
|
64
257
|
end
|
|
65
258
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
tf = Tempfile.new("xlsxrb_zip")
|
|
71
|
-
tf.binmode
|
|
72
|
-
@io.each { |chunk| tf.write(chunk) }
|
|
73
|
-
tf.rewind
|
|
74
|
-
tf
|
|
75
|
-
else
|
|
76
|
-
StringIO.new(@io.read.b)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
io.binmode if io.respond_to?(:binmode)
|
|
80
|
-
|
|
81
|
-
first_sig = io.read(4)
|
|
82
|
-
raise ArgumentError, "Invalid magic number: Expected a valid ZIP/XLSX file format (PK\\x03\\x04)" unless first_sig == LOCAL_HEADER_SIG
|
|
259
|
+
# Yields inflated binary chunks directly from the compressed stream.
|
|
260
|
+
#: (String entry_name, ?chunk_size: Integer) ?{ (String) -> void } -> (void | Enumerator[String, void])
|
|
261
|
+
def each_entry_chunk(entry_name, chunk_size: 65_536, &block)
|
|
262
|
+
return enum_for(:each_entry_chunk, entry_name, chunk_size: chunk_size) unless block
|
|
83
263
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
io.unlink
|
|
90
|
-
end
|
|
91
|
-
return cd_result
|
|
264
|
+
stream = open_entry_io(entry_name)
|
|
265
|
+
begin
|
|
266
|
+
stream.each_chunk(chunk_size, &block)
|
|
267
|
+
ensure
|
|
268
|
+
stream.close
|
|
92
269
|
end
|
|
270
|
+
end
|
|
93
271
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
272
|
+
# Returns true if the archive contains the specified entry.
|
|
273
|
+
#: (String name) -> bool
|
|
274
|
+
def entry?(name)
|
|
275
|
+
catalog.key?(name)
|
|
276
|
+
end
|
|
98
277
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
278
|
+
# Returns entry metadata catalog.
|
|
279
|
+
#: () -> Hash[String, Entry]
|
|
280
|
+
def entry_catalog
|
|
281
|
+
catalog
|
|
282
|
+
end
|
|
102
283
|
|
|
103
|
-
|
|
104
|
-
|
|
284
|
+
# Returns array of all entry names.
|
|
285
|
+
#: () -> Array[String]
|
|
286
|
+
def entry_names
|
|
287
|
+
catalog.keys
|
|
288
|
+
end
|
|
105
289
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
290
|
+
# Copies the specified entry to a ZipWriter directly without decompression.
|
|
291
|
+
#: (String entry_name, ZipWriter writer, ?target_path: String?) -> void
|
|
292
|
+
def copy_to_writer(entry_name, writer, target_path: nil)
|
|
293
|
+
entry = catalog[entry_name]
|
|
294
|
+
raise ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
|
|
111
295
|
|
|
112
|
-
|
|
113
|
-
|
|
296
|
+
if entry.compressed_size.zero? && entry.uncompressed_size.positive?
|
|
297
|
+
data = read_entry(entry_name)
|
|
298
|
+
writer.add_binary_entry(target_path || entry_name, data || "")
|
|
299
|
+
return
|
|
300
|
+
end
|
|
114
301
|
|
|
115
|
-
|
|
302
|
+
writer.copy_raw_entry(
|
|
303
|
+
target_path || entry_name,
|
|
304
|
+
io,
|
|
305
|
+
data_offset: entry.data_offset,
|
|
306
|
+
compressed_size: entry.compressed_size,
|
|
307
|
+
uncompressed_size: entry.uncompressed_size,
|
|
308
|
+
crc32: entry.crc32,
|
|
309
|
+
method: entry.method
|
|
310
|
+
)
|
|
311
|
+
end
|
|
116
312
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
313
|
+
# Closes the archive reader and cleans up any temporary resources.
|
|
314
|
+
#: () -> void
|
|
315
|
+
def close
|
|
316
|
+
@raw_io.close if @should_close_io && @raw_io.respond_to?(:close) && !@raw_io.closed?
|
|
317
|
+
if @tempfile
|
|
318
|
+
@tempfile.close
|
|
319
|
+
@tempfile.unlink
|
|
320
|
+
@tempfile = nil
|
|
124
321
|
end
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private
|
|
125
326
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
327
|
+
def io
|
|
328
|
+
@io ||= begin
|
|
329
|
+
prepared = if @raw_io.is_a?(StringIO) || (@raw_io.respond_to?(:read) && @raw_io.respond_to?(:seek) && @raw_io.respond_to?(:pos))
|
|
330
|
+
@raw_io
|
|
331
|
+
elsif @raw_io.respond_to?(:each) || @raw_io.is_a?(Enumerator)
|
|
332
|
+
@tempfile = Tempfile.new("xlsxrb_zip")
|
|
333
|
+
@tempfile.binmode
|
|
334
|
+
@raw_io.each { |chunk| @tempfile.write(chunk) }
|
|
335
|
+
@tempfile.rewind
|
|
336
|
+
@tempfile
|
|
337
|
+
else
|
|
338
|
+
StringIO.new(@raw_io.read.b)
|
|
339
|
+
end
|
|
340
|
+
prepared.binmode if prepared.respond_to?(:binmode)
|
|
341
|
+
prepared
|
|
129
342
|
end
|
|
343
|
+
end
|
|
130
344
|
|
|
131
|
-
|
|
345
|
+
def catalog
|
|
346
|
+
@catalog ||= parse_catalog
|
|
132
347
|
end
|
|
133
348
|
|
|
134
|
-
def
|
|
349
|
+
def parse_catalog
|
|
350
|
+
first_sig = io.read(4)
|
|
351
|
+
raise ArgumentError, "Invalid magic number: Expected a valid ZIP/XLSX file format (PK\\x03\\x04)" unless first_sig == LOCAL_HEADER_SIG
|
|
352
|
+
|
|
353
|
+
# Try Central Directory first
|
|
354
|
+
cd_catalog = parse_catalog_from_central_directory(io)
|
|
355
|
+
return cd_catalog if cd_catalog && !cd_catalog.empty?
|
|
356
|
+
|
|
357
|
+
io.seek(-4, IO::SEEK_CUR) if io.respond_to?(:seek)
|
|
358
|
+
parse_catalog_from_local_headers(io)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def parse_catalog_from_central_directory(io)
|
|
135
362
|
return nil unless io.respond_to?(:seek) && io.respond_to?(:pos)
|
|
136
363
|
|
|
137
364
|
io.seek(0, IO::SEEK_END)
|
|
@@ -165,7 +392,7 @@ module Xlsxrb
|
|
|
165
392
|
cd_header = io.read(42)
|
|
166
393
|
break unless cd_header && cd_header.bytesize == 42
|
|
167
394
|
|
|
168
|
-
_v_made, _v_need, _gp, method, _time, _date,
|
|
395
|
+
_v_made, _v_need, _gp, method, _time, _date, crc, csize, usize, nlen, elen, clen, _dnum, _iattr, _eattr, offset = cd_header.unpack("vvvvvvVVVvvvvvVV")
|
|
169
396
|
entry_name = io.read(nlen).force_encoding("UTF-8")
|
|
170
397
|
io.read(elen + clen)
|
|
171
398
|
|
|
@@ -182,11 +409,17 @@ module Xlsxrb
|
|
|
182
409
|
|
|
183
410
|
local_nlen = local_header[22, 2].unpack1("v")
|
|
184
411
|
local_elen = local_header[24, 2].unpack1("v")
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
412
|
+
data_offset = offset + 30 + local_nlen + local_elen
|
|
413
|
+
|
|
414
|
+
result[entry_name] = Entry.new(
|
|
415
|
+
name: entry_name,
|
|
416
|
+
method: method,
|
|
417
|
+
compressed_size: csize,
|
|
418
|
+
uncompressed_size: usize,
|
|
419
|
+
crc32: crc,
|
|
420
|
+
local_header_offset: offset,
|
|
421
|
+
data_offset: data_offset
|
|
422
|
+
)
|
|
190
423
|
|
|
191
424
|
io.seek(current_cd_pos, IO::SEEK_SET)
|
|
192
425
|
end
|
|
@@ -198,6 +431,62 @@ module Xlsxrb
|
|
|
198
431
|
nil
|
|
199
432
|
end
|
|
200
433
|
|
|
434
|
+
def parse_catalog_from_local_headers(io)
|
|
435
|
+
result = {}
|
|
436
|
+
loop do
|
|
437
|
+
header_pos = io.pos
|
|
438
|
+
sig = io.read(4)
|
|
439
|
+
break unless sig == LOCAL_HEADER_SIG
|
|
440
|
+
|
|
441
|
+
header = io.read(26)
|
|
442
|
+
break unless header && header.bytesize == 26
|
|
443
|
+
|
|
444
|
+
gp_flag = header[2, 2].unpack1("v")
|
|
445
|
+
method = header[4, 2].unpack1("v")
|
|
446
|
+
crc = header[10, 4].unpack1("V")
|
|
447
|
+
compressed_size = header[14, 4].unpack1("V")
|
|
448
|
+
uncompressed_size = header[18, 4].unpack1("V")
|
|
449
|
+
name_len = header[22, 2].unpack1("v")
|
|
450
|
+
extra_len = header[24, 2].unpack1("v")
|
|
451
|
+
|
|
452
|
+
entry_name = io.read(name_len).force_encoding("UTF-8")
|
|
453
|
+
io.read(extra_len)
|
|
454
|
+
data_offset = io.pos
|
|
455
|
+
|
|
456
|
+
next if entry_name.end_with?("/")
|
|
457
|
+
|
|
458
|
+
has_data_descriptor = gp_flag.anybits?(0x08)
|
|
459
|
+
|
|
460
|
+
if has_data_descriptor && compressed_size.zero?
|
|
461
|
+
entry_data, = find_data_descriptor_stream(io, method)
|
|
462
|
+
@cached_entries ||= {}
|
|
463
|
+
@cached_entries[entry_name] = entry_data
|
|
464
|
+
result[entry_name] = Entry.new(
|
|
465
|
+
name: entry_name,
|
|
466
|
+
method: method,
|
|
467
|
+
compressed_size: entry_data.bytesize,
|
|
468
|
+
uncompressed_size: entry_data.bytesize,
|
|
469
|
+
crc32: crc,
|
|
470
|
+
local_header_offset: header_pos,
|
|
471
|
+
data_offset: data_offset
|
|
472
|
+
)
|
|
473
|
+
else
|
|
474
|
+
result[entry_name] = Entry.new(
|
|
475
|
+
name: entry_name,
|
|
476
|
+
method: method,
|
|
477
|
+
compressed_size: compressed_size,
|
|
478
|
+
uncompressed_size: uncompressed_size,
|
|
479
|
+
crc32: crc,
|
|
480
|
+
local_header_offset: header_pos,
|
|
481
|
+
data_offset: data_offset
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
io.seek(compressed_size, IO::SEEK_CUR) if io.respond_to?(:seek)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
result
|
|
488
|
+
end
|
|
489
|
+
|
|
201
490
|
def find_data_descriptor_stream(io, method)
|
|
202
491
|
if method == 8
|
|
203
492
|
inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
|
|
@@ -238,12 +527,10 @@ module Xlsxrb
|
|
|
238
527
|
end
|
|
239
528
|
|
|
240
529
|
def decompress(raw, method)
|
|
241
|
-
return raw&.dup&.force_encoding("UTF-8") || "" if method.zero?
|
|
530
|
+
return raw&.dup&.force_encoding("UTF-8") || "" if method.zero?
|
|
242
531
|
|
|
243
|
-
# Deflated
|
|
244
532
|
safe_inflate(raw || "", -Zlib::MAX_WBITS)
|
|
245
533
|
rescue Zlib::DataError
|
|
246
|
-
# Try with raw deflate (no header)
|
|
247
534
|
safe_inflate(raw || "", -Zlib::MAX_WBITS)
|
|
248
535
|
end
|
|
249
536
|
|
|
@@ -264,7 +551,6 @@ module Xlsxrb
|
|
|
264
551
|
result << inflated
|
|
265
552
|
offset += chunk_size
|
|
266
553
|
end
|
|
267
|
-
# Finish inflation
|
|
268
554
|
inflated = inflater.finish
|
|
269
555
|
raise ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
|
|
270
556
|
|
|
@@ -109,7 +109,33 @@ module Xlsxrb
|
|
|
109
109
|
compressed_size: @entry_compressed_size,
|
|
110
110
|
uncompressed_size: @entry_uncompressed_size,
|
|
111
111
|
offset: @entry_offset,
|
|
112
|
-
gp_flag: @entry_gp_flag
|
|
112
|
+
gp_flag: @entry_gp_flag,
|
|
113
|
+
method: 8
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Copies an entry from an existing archive IO without decompressing or recompressing.
|
|
118
|
+
#: (String path, untyped source_io, data_offset: Integer, compressed_size: Integer, uncompressed_size: Integer, crc32: Integer, ?method: Integer) -> void
|
|
119
|
+
def copy_raw_entry(path, source_io, data_offset:, compressed_size:, uncompressed_size:, crc32:, method: 8)
|
|
120
|
+
raise "ZipWriter is closed" if @closed
|
|
121
|
+
|
|
122
|
+
offset = @bytes_written
|
|
123
|
+
write_local_header(path, crc32, compressed_size, uncompressed_size, gp_flag: 0, method: method)
|
|
124
|
+
|
|
125
|
+
if compressed_size.positive?
|
|
126
|
+
source_io.seek(data_offset, IO::SEEK_SET)
|
|
127
|
+
IO.copy_stream(source_io, @io, compressed_size)
|
|
128
|
+
@bytes_written += compressed_size
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@entries << {
|
|
132
|
+
path: path,
|
|
133
|
+
crc32: crc32,
|
|
134
|
+
compressed_size: compressed_size,
|
|
135
|
+
uncompressed_size: uncompressed_size,
|
|
136
|
+
offset: offset,
|
|
137
|
+
gp_flag: 0,
|
|
138
|
+
method: method
|
|
113
139
|
}
|
|
114
140
|
end
|
|
115
141
|
|
|
@@ -159,7 +185,8 @@ module Xlsxrb
|
|
|
159
185
|
compressed_size: compressed.bytesize,
|
|
160
186
|
uncompressed_size: content_bytes.bytesize,
|
|
161
187
|
offset: offset,
|
|
162
|
-
gp_flag: 0
|
|
188
|
+
gp_flag: 0,
|
|
189
|
+
method: 8
|
|
163
190
|
}
|
|
164
191
|
end
|
|
165
192
|
|
|
@@ -172,13 +199,13 @@ module Xlsxrb
|
|
|
172
199
|
end
|
|
173
200
|
end
|
|
174
201
|
|
|
175
|
-
def write_local_header(path, crc, compressed_size, uncompressed_size, gp_flag: 0, count_bytes: true)
|
|
202
|
+
def write_local_header(path, crc, compressed_size, uncompressed_size, gp_flag: 0, count_bytes: true, method: 8)
|
|
176
203
|
name_bytes = path.encode("UTF-8").b
|
|
177
204
|
header = [
|
|
178
205
|
0x04034B50, # local file header signature
|
|
179
206
|
20, # version needed (2.0)
|
|
180
207
|
gp_flag, # general purpose bit flag (0x0008 if data descriptor follows)
|
|
181
|
-
|
|
208
|
+
method, # compression method (0 = store, 8 = deflate)
|
|
182
209
|
0, # last mod file time
|
|
183
210
|
33, # last mod file date
|
|
184
211
|
crc,
|
|
@@ -206,7 +233,7 @@ module Xlsxrb
|
|
|
206
233
|
20, # version made by
|
|
207
234
|
20, # version needed
|
|
208
235
|
entry[:gp_flag] || 0, # general purpose bit flag
|
|
209
|
-
8,
|
|
236
|
+
entry[:method] || 8, # compression method
|
|
210
237
|
0, # last mod file time
|
|
211
238
|
33, # last mod file date
|
|
212
239
|
entry[:crc32],
|