xlsxrb 0.1.11 → 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 +86 -34
- data/README.md +26 -21
- data/lib/xlsxrb/chart_builder.rb +18 -18
- data/lib/xlsxrb/dsl_helpers.rb +175 -0
- data/lib/xlsxrb/elements/cell.rb +56 -22
- data/lib/xlsxrb/elements/column.rb +27 -3
- data/lib/xlsxrb/elements/row.rb +27 -8
- data/lib/xlsxrb/elements/workbook.rb +27 -14
- data/lib/xlsxrb/elements/worksheet.rb +17 -1
- data/lib/xlsxrb/ooxml/cfb.rb +9 -6
- data/lib/xlsxrb/ooxml/crypto/agile.rb +5 -5
- data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +784 -0
- data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +3478 -0
- data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +1796 -0
- data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +428 -0
- data/lib/xlsxrb/ooxml/reader/listeners.rb +4 -6446
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +3 -5
- data/lib/xlsxrb/ooxml/utils.rb +5 -6
- data/lib/xlsxrb/ooxml/workbook_writer.rb +5 -7
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +172 -9
- data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +1602 -0
- data/lib/xlsxrb/ooxml/writer/features_xml.rb +731 -0
- data/lib/xlsxrb/ooxml/writer/styles_xml.rb +348 -0
- data/lib/xlsxrb/ooxml/writer.rb +9 -2646
- data/lib/xlsxrb/ooxml/xml_builder.rb +31 -2
- data/lib/xlsxrb/ooxml/zip_generator.rb +26 -5
- 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 +48 -115
- data/lib/xlsxrb/style_builder.rb +10 -1
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +1 -1
- data/lib/xlsxrb/worksheet_builder.rb +19 -81
- data/lib/xlsxrb.rb +449 -171
- data/sig/generated/xlsxrb/chart_builder.rbs +36 -36
- data/sig/generated/xlsxrb/dsl_helpers.rbs +92 -0
- data/sig/generated/xlsxrb/elements/cell.rbs +29 -0
- data/sig/generated/xlsxrb/elements/column.rbs +32 -21
- data/sig/generated/xlsxrb/elements/row.rbs +94 -14
- data/sig/generated/xlsxrb/elements/workbook.rbs +102 -10
- data/sig/generated/xlsxrb/elements/worksheet.rbs +9 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +173 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +400 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +442 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +80 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners.rbs +0 -1072
- data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +0 -2
- data/sig/generated/xlsxrb/ooxml/utils.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +5 -4
- data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +79 -0
- data/sig/generated/xlsxrb/ooxml/writer/features_xml.rbs +83 -0
- data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +53 -0
- data/sig/generated/xlsxrb/ooxml/writer.rbs +6 -185
- data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +18 -0
- data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +15 -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/style_builder.rbs +7 -0
- data/sig/generated/xlsxrb.rbs +18 -11
- metadata +22 -144
- 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/Rakefile +0 -567
- data/Steepfile +0 -18
- data/benchmark.rb +0 -406
- data/docs/ARCHITECTURE.md +0 -514
- data/docs/DEVELOPMENT.md +0 -133
- data/docs/PEER_LIBRARIES.md +0 -121
- data/docs/QUALITY_ASSURANCE.md +0 -25
- 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 -4672
- 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
|
@@ -98,6 +98,36 @@ module Xlsxrb
|
|
|
98
98
|
@io.is_a?(StringIO) ? @io.string : @io.to_s
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
UNESCAPE_MAP = {
|
|
102
|
+
"&" => "&",
|
|
103
|
+
"<" => "<",
|
|
104
|
+
">" => ">",
|
|
105
|
+
""" => '"',
|
|
106
|
+
"'" => "'"
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
109
|
+
UNESCAPE_RE = /&(?:amp|lt|gt|quot|apos);/
|
|
110
|
+
|
|
111
|
+
# Escapes XML special characters in a string.
|
|
112
|
+
#
|
|
113
|
+
# @param value [Object]
|
|
114
|
+
# @return [String]
|
|
115
|
+
#: (untyped value) -> String
|
|
116
|
+
def self.escape(value)
|
|
117
|
+
str = value.to_s
|
|
118
|
+
str.match?(ESCAPE_RE) ? str.gsub(ESCAPE_RE, ESCAPE_MAP) : str
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Unescapes predefined XML entities in a string in a single pass.
|
|
122
|
+
#
|
|
123
|
+
# @param value [Object]
|
|
124
|
+
# @return [String]
|
|
125
|
+
#: (untyped value) -> String
|
|
126
|
+
def self.unescape(value)
|
|
127
|
+
str = value.to_s
|
|
128
|
+
str.include?("&") ? str.gsub(UNESCAPE_RE, UNESCAPE_MAP) : str
|
|
129
|
+
end
|
|
130
|
+
|
|
101
131
|
private
|
|
102
132
|
|
|
103
133
|
def write_attrs(attrs)
|
|
@@ -113,8 +143,7 @@ module Xlsxrb
|
|
|
113
143
|
end
|
|
114
144
|
|
|
115
145
|
def escape(value)
|
|
116
|
-
|
|
117
|
-
str.match?(ESCAPE_RE) ? str.gsub(ESCAPE_RE, ESCAPE_MAP) : str
|
|
146
|
+
self.class.escape(value)
|
|
118
147
|
end
|
|
119
148
|
end
|
|
120
149
|
end
|
|
@@ -37,6 +37,29 @@ module Xlsxrb
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Returns little-endian 16-bit unsigned integer as an Array of byte values.
|
|
41
|
+
#: (Integer value) -> Array[Integer]
|
|
42
|
+
def self.le16(value)
|
|
43
|
+
[value].pack("v").bytes
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns little-endian 32-bit unsigned integer as an Array of byte values.
|
|
47
|
+
#: (Integer value) -> Array[Integer]
|
|
48
|
+
def self.le32(value)
|
|
49
|
+
[value].pack("V").bytes
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns MS-DOS format datetime byte array (little-endian 16-bit time + date).
|
|
53
|
+
#
|
|
54
|
+
# @param time [Time]
|
|
55
|
+
# @return [Array<Integer>]
|
|
56
|
+
#: (Time time) -> Array[Integer]
|
|
57
|
+
def self.dos_datetime(time)
|
|
58
|
+
dos_date = ((time.year - 1980) << 9) + (time.month << 5) + time.day
|
|
59
|
+
dos_time = (time.hour << 11) + (time.min << 5) + (time.sec / 2)
|
|
60
|
+
le16(dos_time) + le16(dos_date)
|
|
61
|
+
end
|
|
62
|
+
|
|
40
63
|
private
|
|
41
64
|
|
|
42
65
|
def write_entries
|
|
@@ -138,17 +161,15 @@ module Xlsxrb
|
|
|
138
161
|
end
|
|
139
162
|
|
|
140
163
|
def dos_datetime(time)
|
|
141
|
-
|
|
142
|
-
dos_time = (time.hour << 11) | (time.min << 5) | (time.sec / 2)
|
|
143
|
-
le16(dos_time) + le16(dos_date)
|
|
164
|
+
self.class.dos_datetime(time)
|
|
144
165
|
end
|
|
145
166
|
|
|
146
167
|
def le16(value)
|
|
147
|
-
|
|
168
|
+
self.class.le16(value)
|
|
148
169
|
end
|
|
149
170
|
|
|
150
171
|
def le32(value)
|
|
151
|
-
|
|
172
|
+
self.class.le32(value)
|
|
152
173
|
end
|
|
153
174
|
|
|
154
175
|
def calculate_central_dir_size
|
|
@@ -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
|
|