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.
Files changed (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -34
  3. data/README.md +26 -21
  4. data/lib/xlsxrb/chart_builder.rb +18 -18
  5. data/lib/xlsxrb/dsl_helpers.rb +175 -0
  6. data/lib/xlsxrb/elements/cell.rb +56 -22
  7. data/lib/xlsxrb/elements/column.rb +27 -3
  8. data/lib/xlsxrb/elements/row.rb +27 -8
  9. data/lib/xlsxrb/elements/workbook.rb +27 -14
  10. data/lib/xlsxrb/elements/worksheet.rb +17 -1
  11. data/lib/xlsxrb/ooxml/cfb.rb +9 -6
  12. data/lib/xlsxrb/ooxml/crypto/agile.rb +5 -5
  13. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +784 -0
  14. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +3478 -0
  15. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +1796 -0
  16. data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +428 -0
  17. data/lib/xlsxrb/ooxml/reader/listeners.rb +4 -6446
  18. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +3 -5
  19. data/lib/xlsxrb/ooxml/utils.rb +5 -6
  20. data/lib/xlsxrb/ooxml/workbook_writer.rb +5 -7
  21. data/lib/xlsxrb/ooxml/worksheet_parser.rb +172 -9
  22. data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +1602 -0
  23. data/lib/xlsxrb/ooxml/writer/features_xml.rb +731 -0
  24. data/lib/xlsxrb/ooxml/writer/styles_xml.rb +348 -0
  25. data/lib/xlsxrb/ooxml/writer.rb +9 -2646
  26. data/lib/xlsxrb/ooxml/xml_builder.rb +31 -2
  27. data/lib/xlsxrb/ooxml/zip_generator.rb +26 -5
  28. data/lib/xlsxrb/ooxml/zip_reader.rb +364 -78
  29. data/lib/xlsxrb/ooxml/zip_writer.rb +32 -5
  30. data/lib/xlsxrb/stream_sheet.rb +94 -6
  31. data/lib/xlsxrb/stream_writer.rb +48 -115
  32. data/lib/xlsxrb/style_builder.rb +10 -1
  33. data/lib/xlsxrb/version.rb +1 -1
  34. data/lib/xlsxrb/workbook_builder.rb +1 -1
  35. data/lib/xlsxrb/worksheet_builder.rb +19 -81
  36. data/lib/xlsxrb.rb +449 -171
  37. data/sig/generated/xlsxrb/chart_builder.rbs +36 -36
  38. data/sig/generated/xlsxrb/dsl_helpers.rbs +92 -0
  39. data/sig/generated/xlsxrb/elements/cell.rbs +29 -0
  40. data/sig/generated/xlsxrb/elements/column.rbs +32 -21
  41. data/sig/generated/xlsxrb/elements/row.rbs +94 -14
  42. data/sig/generated/xlsxrb/elements/workbook.rbs +102 -10
  43. data/sig/generated/xlsxrb/elements/worksheet.rbs +9 -0
  44. data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +173 -0
  45. data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +400 -0
  46. data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +442 -0
  47. data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +80 -0
  48. data/sig/generated/xlsxrb/ooxml/reader/listeners.rbs +0 -1072
  49. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +0 -2
  50. data/sig/generated/xlsxrb/ooxml/utils.rbs +2 -0
  51. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +5 -4
  52. data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +79 -0
  53. data/sig/generated/xlsxrb/ooxml/writer/features_xml.rbs +83 -0
  54. data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +53 -0
  55. data/sig/generated/xlsxrb/ooxml/writer.rbs +6 -185
  56. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +18 -0
  57. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +15 -0
  58. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +96 -8
  59. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +5 -1
  60. data/sig/generated/xlsxrb/stream_sheet.rbs +43 -3
  61. data/sig/generated/xlsxrb/style_builder.rbs +7 -0
  62. data/sig/generated/xlsxrb.rbs +18 -11
  63. metadata +22 -144
  64. data/.devcontainer/Dockerfile +0 -65
  65. data/.devcontainer/devcontainer.json +0 -17
  66. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +0 -9
  67. data/.gem_rbs_collection/ast/2.4/ast.rbs +0 -73
  68. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +0 -9
  69. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +0 -4
  70. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +0 -16
  71. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +0 -96
  72. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +0 -4
  73. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +0 -68
  74. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +0 -249
  75. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +0 -4
  76. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +0 -47
  77. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +0 -5
  78. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +0 -9
  79. data/.gem_rbs_collection/csv/3.3/csv.rbs +0 -3871
  80. data/.gem_rbs_collection/csv/3.3/manifest.yaml +0 -3
  81. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +0 -9
  82. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +0 -48
  83. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +0 -9
  84. data/.gem_rbs_collection/listen/3.9/listen.rbs +0 -25
  85. data/.gem_rbs_collection/listen/3.9/listener.rbs +0 -24
  86. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +0 -9
  87. data/.gem_rbs_collection/logger/1.7/formatter.rbs +0 -45
  88. data/.gem_rbs_collection/logger/1.7/log_device.rbs +0 -100
  89. data/.gem_rbs_collection/logger/1.7/logger.rbs +0 -796
  90. data/.gem_rbs_collection/logger/1.7/manifest.yaml +0 -2
  91. data/.gem_rbs_collection/logger/1.7/period.rbs +0 -17
  92. data/.gem_rbs_collection/logger/1.7/severity.rbs +0 -34
  93. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +0 -9
  94. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +0 -2332
  95. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +0 -4
  96. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +0 -9
  97. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +0 -86
  98. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +0 -9
  99. data/.gem_rbs_collection/parser/3.2/manifest.yaml +0 -7
  100. data/.gem_rbs_collection/parser/3.2/parser.rbs +0 -194
  101. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +0 -4
  102. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +0 -9
  103. data/.gem_rbs_collection/rainbow/3.0/global.rbs +0 -7
  104. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +0 -209
  105. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +0 -5
  106. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +0 -9
  107. data/.gem_rbs_collection/rake/13.0/manifest.yaml +0 -2
  108. data/.gem_rbs_collection/rake/13.0/rake.rbs +0 -39
  109. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +0 -9
  110. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +0 -17
  111. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +0 -9
  112. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +0 -208
  113. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +0 -9
  114. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +0 -903
  115. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +0 -9
  116. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +0 -8
  117. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +0 -42
  118. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +0 -5
  119. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +0 -47
  120. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +0 -30
  121. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +0 -9
  122. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +0 -7
  123. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +0 -19
  124. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +0 -31
  125. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +0 -18
  126. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +0 -12
  127. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +0 -11
  128. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +0 -13
  129. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +0 -95
  130. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +0 -31
  131. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +0 -58
  132. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +0 -24
  133. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +0 -17
  134. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +0 -23
  135. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +0 -22
  136. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +0 -30
  137. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +0 -20
  138. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +0 -15
  139. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +0 -26
  140. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +0 -21
  141. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +0 -131
  142. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +0 -14
  143. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +0 -33
  144. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +0 -21
  145. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +0 -63
  146. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +0 -55
  147. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +0 -35
  148. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +0 -7
  149. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +0 -10
  150. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +0 -22
  151. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +0 -29
  152. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +0 -17
  153. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +0 -13
  154. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +0 -10
  155. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +0 -8
  156. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +0 -6
  157. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +0 -30
  158. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +0 -10
  159. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +0 -10
  160. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +0 -5
  161. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +0 -15
  162. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +0 -3
  163. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +0 -40
  164. data/Rakefile +0 -567
  165. data/Steepfile +0 -18
  166. data/benchmark.rb +0 -406
  167. data/docs/ARCHITECTURE.md +0 -514
  168. data/docs/DEVELOPMENT.md +0 -133
  169. data/docs/PEER_LIBRARIES.md +0 -121
  170. data/docs/QUALITY_ASSURANCE.md +0 -25
  171. data/docs/SPEC_SOURCES.md +0 -48
  172. data/docs/assets/benchmark_results.svg +0 -144
  173. data/docs/assets/lsp_autocompletion.png +0 -0
  174. data/docs/assets/playground_preview.png +0 -0
  175. data/docs/coi-serviceworker.js +0 -82
  176. data/docs/office_thread.js +0 -77
  177. data/docs/preview.html +0 -719
  178. data/docs/visual/VisualGallery.md +0 -4672
  179. data/docs/wasm/ruby.wasm +0 -0
  180. data/docs/wasm/wasm_doc_helper.css +0 -351
  181. data/docs/wasm/wasm_doc_helper.js +0 -439
  182. data/docs/zeta.js +0 -1107
  183. data/rbs_collection.lock.yaml +0 -252
  184. data/rbs_collection.yaml +0 -19
  185. data/vendor/sdk_runner/Program.cs +0 -93
  186. 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
+ "&lt;" => "<",
104
+ "&gt;" => ">",
105
+ "&quot;" => '"',
106
+ "&apos;" => "'"
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
- str = value.to_s
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
- dos_date = ((time.year - 1980) << 9) | (time.month << 5) | time.day
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
- [value].pack("v").bytes
168
+ self.class.le16(value)
148
169
  end
149
170
 
150
171
  def le32(value)
151
- [value].pack("V").bytes
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
- # Scans local file headers sequentially — works with non-seekable IO.
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
- io = source.is_a?(String) ? File.open(source, "rb") : source
22
- reader = new(io, max_uncompressed_size: max_uncompressed_size)
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
- io.close if source.is_a?(String)
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
- @io = io
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
- @entries = nil
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
- each_entry { |name, data| result[name] = data }
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
- entries[name]
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
- entries.each_pair(&block)
245
+ catalog.each_key do |name|
246
+ block.call(name, read_entry(name))
247
+ end
58
248
  end
59
249
 
60
- private
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
- def entries
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
- def parse_entries
67
- io = if @io.is_a?(StringIO) || (@io.respond_to?(:read) && @io.respond_to?(:seek) && @io.respond_to?(:pos))
68
- @io
69
- elsif @io.respond_to?(:each) || @io.is_a?(Enumerator)
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
- # Try parsing via Central Directory first (standard and handles Data Descriptors perfectly)
85
- cd_result = parse_from_central_directory(io)
86
- if cd_result && !cd_result.empty?
87
- if io.is_a?(Tempfile)
88
- io.close
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
- io.seek(-4, IO::SEEK_CUR) if io.respond_to?(:seek)
95
- io = StringIO.new(first_sig + io.read.b) unless io.respond_to?(:seek)
96
-
97
- result = {}
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
- loop do
100
- sig = io.read(4)
101
- break unless sig == LOCAL_HEADER_SIG
278
+ # Returns entry metadata catalog.
279
+ #: () -> Hash[String, Entry]
280
+ def entry_catalog
281
+ catalog
282
+ end
102
283
 
103
- header = io.read(26)
104
- break unless header && header.bytesize == 26
284
+ # Returns array of all entry names.
285
+ #: () -> Array[String]
286
+ def entry_names
287
+ catalog.keys
288
+ end
105
289
 
106
- gp_flag = header[2, 2].unpack1("v")
107
- method = header[4, 2].unpack1("v")
108
- compressed_size = header[14, 4].unpack1("V")
109
- name_len = header[22, 2].unpack1("v")
110
- extra_len = header[24, 2].unpack1("v")
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
- entry_name = io.read(name_len).force_encoding("UTF-8")
113
- io.read(extra_len) # skip extra
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
- has_data_descriptor = gp_flag.anybits?(0x08)
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
- if has_data_descriptor && compressed_size.zero?
118
- entry_data, = find_data_descriptor_stream(io, method)
119
- else
120
- raw = io.read(compressed_size)
121
- entry_data = decompress(raw, method)
122
- end
123
- result[entry_name] = entry_data unless entry_name.end_with?("/")
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
- if io.is_a?(Tempfile)
127
- io.close
128
- io.unlink
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
- result
345
+ def catalog
346
+ @catalog ||= parse_catalog
132
347
  end
133
348
 
134
- def parse_from_central_directory(io)
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, _crc, csize, _usize, nlen, elen, clen, _dnum, _iattr, _eattr, offset = cd_header.unpack("vvvvvvVVVvvvvvVV")
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
- io.seek(offset + 30 + local_nlen + local_elen, IO::SEEK_SET)
186
-
187
- raw = io.read(csize)
188
- entry_data = decompress(raw, method)
189
- result[entry_name] = entry_data
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? # stored
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