xlsxrb 0.1.4 → 0.1.6

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +1 -1
  3. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +9 -0
  4. data/.gem_rbs_collection/ast/2.4/ast.rbs +73 -0
  5. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  6. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  7. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +16 -0
  8. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +96 -0
  9. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  10. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +68 -0
  11. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  12. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +4 -0
  13. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +47 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  15. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +9 -0
  16. data/.gem_rbs_collection/csv/3.3/csv.rbs +3871 -0
  17. data/.gem_rbs_collection/csv/3.3/manifest.yaml +3 -0
  18. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +9 -0
  19. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +48 -0
  20. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  21. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  22. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  23. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/logger/1.7/formatter.rbs +45 -0
  25. data/.gem_rbs_collection/logger/1.7/log_device.rbs +100 -0
  26. data/.gem_rbs_collection/logger/1.7/logger.rbs +796 -0
  27. data/.gem_rbs_collection/logger/1.7/manifest.yaml +2 -0
  28. data/.gem_rbs_collection/logger/1.7/period.rbs +17 -0
  29. data/.gem_rbs_collection/logger/1.7/severity.rbs +34 -0
  30. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
  32. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
  33. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  35. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/parser/3.2/manifest.yaml +7 -0
  37. data/.gem_rbs_collection/parser/3.2/parser.rbs +194 -0
  38. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +4 -0
  39. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +9 -0
  40. data/.gem_rbs_collection/rainbow/3.0/global.rbs +7 -0
  41. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +209 -0
  42. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +5 -0
  43. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  44. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  45. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  46. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +9 -0
  47. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +17 -0
  48. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +9 -0
  49. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +208 -0
  50. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  51. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +903 -0
  52. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
  53. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
  54. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
  55. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
  56. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
  57. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
  58. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
  59. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
  60. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
  61. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
  62. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
  63. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
  64. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
  65. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
  66. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
  67. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
  68. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
  69. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
  70. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
  71. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
  72. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
  73. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
  74. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
  75. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
  76. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
  77. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
  78. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
  79. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
  80. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
  81. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
  82. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
  83. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
  84. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
  85. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
  86. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
  87. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
  88. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
  89. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
  90. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
  91. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
  92. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
  93. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
  94. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
  95. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
  96. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
  97. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
  98. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
  99. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
  100. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
  101. data/CHANGELOG.md +42 -3
  102. data/README.md +130 -49
  103. data/Rakefile +23 -2
  104. data/Steepfile +17 -0
  105. data/benchmark.rb +324 -348
  106. data/docs/ARCHITECTURE.md +24 -0
  107. data/docs/DEVELOPMENT.md +58 -0
  108. data/docs/QUALITY_ASSURANCE.md +26 -0
  109. data/docs/visual/VisualGallery.md +893 -938
  110. data/docs/wasm/ruby.wasm +0 -0
  111. data/lib/xlsxrb/elements/cell.rb +113 -8
  112. data/lib/xlsxrb/elements/column.rb +2 -0
  113. data/lib/xlsxrb/elements/row.rb +52 -4
  114. data/lib/xlsxrb/elements/types.rb +6 -0
  115. data/lib/xlsxrb/elements/workbook.rb +29 -0
  116. data/lib/xlsxrb/elements/worksheet.rb +98 -1
  117. data/lib/xlsxrb/elements.rb +2 -0
  118. data/lib/xlsxrb/ooxml/reader.rb +8 -6
  119. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +106 -40
  120. data/lib/xlsxrb/ooxml/styles_parser.rb +2 -0
  121. data/lib/xlsxrb/ooxml/utils.rb +2 -0
  122. data/lib/xlsxrb/ooxml/workbook_parser.rb +6 -1
  123. data/lib/xlsxrb/ooxml/workbook_writer.rb +15 -4
  124. data/lib/xlsxrb/ooxml/worksheet_parser.rb +156 -23
  125. data/lib/xlsxrb/ooxml/worksheet_writer.rb +207 -152
  126. data/lib/xlsxrb/ooxml/writer.rb +217 -5
  127. data/lib/xlsxrb/ooxml/xml_builder.rb +18 -10
  128. data/lib/xlsxrb/ooxml/xml_parser.rb +2 -0
  129. data/lib/xlsxrb/ooxml/zip_generator.rb +5 -8
  130. data/lib/xlsxrb/ooxml/zip_reader.rb +179 -57
  131. data/lib/xlsxrb/ooxml/zip_writer.rb +65 -25
  132. data/lib/xlsxrb/ooxml.rb +3 -1
  133. data/lib/xlsxrb/style_builder.rb +284 -36
  134. data/lib/xlsxrb/version.rb +3 -1
  135. data/lib/xlsxrb.rb +1761 -275
  136. data/rbs_collection.lock.yaml +252 -0
  137. data/rbs_collection.yaml +19 -0
  138. data/sig/generated/xlsxrb/elements/cell.rbs +30 -0
  139. data/sig/generated/xlsxrb/elements/column.rbs +30 -0
  140. data/sig/generated/xlsxrb/elements/row.rbs +32 -0
  141. data/sig/generated/xlsxrb/elements/types.rbs +74 -0
  142. data/sig/generated/xlsxrb/elements/workbook.rbs +25 -0
  143. data/sig/generated/xlsxrb/elements/worksheet.rbs +27 -0
  144. data/sig/generated/xlsxrb/elements.rbs +8 -0
  145. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  146. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  147. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  148. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  149. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  150. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  151. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  152. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  153. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  154. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  155. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  156. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  157. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  158. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  159. data/sig/generated/xlsxrb/ooxml.rbs +23 -0
  160. data/sig/generated/xlsxrb/style_builder.rbs +250 -0
  161. data/sig/generated/xlsxrb/version.rbs +5 -0
  162. data/sig/generated/xlsxrb.rbs +1276 -0
  163. data/sig/rexml.rbs +4 -0
  164. metadata +145 -4
  165. data/measure_memory.rb +0 -42
  166. data/sig/xlsxrb.rbs +0 -23
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Ooxml
5
7
  # Streams well-formed XML to a writable IO without building a DOM.
@@ -25,8 +27,10 @@ module Xlsxrb
25
27
  self
26
28
  end
27
29
 
30
+ EMPTY_HASH = {}.freeze
31
+
28
32
  # Opens a tag, yields for children, then closes the tag.
29
- def tag(name, attrs = {}, &block)
33
+ def tag(name, attrs = EMPTY_HASH, &block)
30
34
  if block
31
35
  open_tag(name, attrs)
32
36
  yield self
@@ -37,22 +41,26 @@ module Xlsxrb
37
41
  self
38
42
  end
39
43
 
40
- def open_tag(name, attrs = {})
41
- @io << "<#{name}"
42
- write_attrs(attrs)
43
- @io << ">"
44
+ def open_tag(name, attrs = EMPTY_HASH)
45
+ @io.write("<")
46
+ @io.write(name)
47
+ write_attrs(attrs) unless attrs.empty?
48
+ @io.write(">")
44
49
  self
45
50
  end
46
51
 
47
52
  def close_tag(name)
48
- @io << "</#{name}>"
53
+ @io.write("</")
54
+ @io.write(name)
55
+ @io.write(">")
49
56
  self
50
57
  end
51
58
 
52
- def empty_tag(name, attrs = {})
53
- @io << "<#{name}"
54
- write_attrs(attrs)
55
- @io << "/>"
59
+ def empty_tag(name, attrs = EMPTY_HASH)
60
+ @io.write("<")
61
+ @io.write(name)
62
+ write_attrs(attrs) unless attrs.empty?
63
+ @io.write("/>")
56
64
  self
57
65
  end
58
66
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "rexml/parsers/sax2parser"
4
6
  require "rexml/sax2listener"
5
7
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "zlib"
4
6
 
5
7
  module Xlsxrb
@@ -142,20 +144,15 @@ module Xlsxrb
142
144
  end
143
145
 
144
146
  def le16(value)
145
- [(value & 0xFF), ((value >> 8) & 0xFF)]
147
+ [value].pack("v").bytes
146
148
  end
147
149
 
148
150
  def le32(value)
149
- [(value & 0xFF), ((value >> 8) & 0xFF), ((value >> 16) & 0xFF), ((value >> 24) & 0xFF)]
151
+ [value].pack("V").bytes
150
152
  end
151
153
 
152
154
  def calculate_central_dir_size
153
- size = 0
154
- @entries.each do |entry|
155
- filename = entry[:path]
156
- size += 46 + filename.bytesize
157
- end
158
- size
155
+ @entries.sum { |entry| 46 + entry[:path].bytesize }
159
156
  end
160
157
  end
161
158
  end
@@ -1,19 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "zlib"
4
6
  require "stringio"
7
+ require "tempfile"
5
8
 
6
9
  module Xlsxrb
7
10
  module Ooxml
8
11
  # Reads ZIP archives using only stdlib (zlib).
9
12
  # Scans local file headers sequentially — works with non-seekable IO.
10
13
  class ZipReader
11
- LOCAL_HEADER_SIG = [0x50, 0x4B, 0x03, 0x04].pack("C4")
14
+ LOCAL_HEADER_SIG = "PK\x03\x04".b
15
+ MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024 # 500MB per file limit
16
+ attr_accessor :max_uncompressed_size
12
17
 
13
18
  # Opens a ZIP from a file path or IO and yields the reader.
14
- def self.open(source)
19
+ #: (untyped source, ?max_uncompressed_size: Integer) ?{ (ZipReader) -> untyped } -> (ZipReader | untyped)
20
+ def self.open(source, max_uncompressed_size: MAX_UNCOMPRESSED_SIZE)
15
21
  io = source.is_a?(String) ? File.open(source, "rb") : source
16
- reader = new(io)
22
+ reader = new(io, max_uncompressed_size: max_uncompressed_size)
17
23
  if block_given?
18
24
  begin
19
25
  yield reader
@@ -25,8 +31,10 @@ module Xlsxrb
25
31
  end
26
32
  end
27
33
 
28
- def initialize(io)
34
+ #: (untyped io, ?max_uncompressed_size: Integer) -> void
35
+ def initialize(io, max_uncompressed_size: MAX_UNCOMPRESSED_SIZE)
29
36
  @io = io
37
+ @max_uncompressed_size = max_uncompressed_size
30
38
  @entries = nil
31
39
  end
32
40
 
@@ -56,86 +64,176 @@ module Xlsxrb
56
64
  end
57
65
 
58
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
83
+
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
92
+ end
93
+
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
+
59
97
  result = {}
60
- data = @io.is_a?(StringIO) ? @io.string : @io.read
61
- data = data.b
62
- pos = 0
63
98
 
64
- while pos + 4 <= data.bytesize
65
- sig = data[pos, 4]
99
+ loop do
100
+ sig = io.read(4)
66
101
  break unless sig == LOCAL_HEADER_SIG
67
102
 
68
- # Local file header: 4 sig + 2 version + 2 flags + 2 method + 2 time + 2 date
69
- # + 4 crc + 4 compressed + 4 uncompressed + 2 name_len + 2 extra_len = 30 bytes
70
- break if pos + 30 > data.bytesize
103
+ header = io.read(26)
104
+ break unless header && header.bytesize == 26
71
105
 
72
- gp_flag = data[pos + 6, 2].unpack1("v")
73
- method = data[pos + 8, 2].unpack1("v")
74
- data[pos + 14, 4].unpack1("V")
75
- compressed_size = data[pos + 18, 4].unpack1("V")
76
- data[pos + 22, 4].unpack1("V")
77
- name_len = data[pos + 26, 2].unpack1("v")
78
- extra_len = data[pos + 28, 2].unpack1("v")
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")
79
111
 
80
- entry_name = data[pos + 30, name_len].force_encoding("UTF-8")
81
- file_data_offset = pos + 30 + name_len + extra_len
112
+ entry_name = io.read(name_len).force_encoding("UTF-8")
113
+ io.read(extra_len) # skip extra
82
114
 
83
- # Data descriptor present if bit 3 of gp_flag is set
84
115
  has_data_descriptor = gp_flag.anybits?(0x08)
85
116
 
86
117
  if has_data_descriptor && compressed_size.zero?
87
- # Need to find the data descriptor to know sizes
88
- raw, comp_sz, = find_data_descriptor(data, file_data_offset, method)
89
- entry_data = decompress(raw, method)
90
- result[entry_name] = entry_data unless entry_name.end_with?("/")
91
- # Skip past data + descriptor (12 or 16 bytes)
92
- desc_offset = file_data_offset + comp_sz
93
- # Check for optional signature
94
- pos = if desc_offset + 4 <= data.bytesize && data[desc_offset, 4] == [0x50, 0x4B, 0x07, 0x08].pack("C4")
95
- desc_offset + 16
96
- else
97
- desc_offset + 12
98
- end
118
+ entry_data, = find_data_descriptor_stream(io, method)
99
119
  else
100
- raw = data[file_data_offset, compressed_size]
120
+ raw = io.read(compressed_size)
101
121
  entry_data = decompress(raw, method)
102
- result[entry_name] = entry_data unless entry_name.end_with?("/")
103
- pos = file_data_offset + compressed_size
104
122
  end
123
+ result[entry_name] = entry_data unless entry_name.end_with?("/")
124
+ end
125
+
126
+ if io.is_a?(Tempfile)
127
+ io.close
128
+ io.unlink
105
129
  end
106
130
 
107
131
  result
108
132
  end
109
133
 
110
- def find_data_descriptor(data, offset, method)
111
- # For deflated data, we inflate to find the end
134
+ def parse_from_central_directory(io)
135
+ return nil unless io.respond_to?(:seek) && io.respond_to?(:pos)
136
+
137
+ io.seek(0, IO::SEEK_END)
138
+ file_size = io.pos
139
+ return nil if file_size < 22
140
+
141
+ max_search = [file_size, 65_557].min
142
+ search_offset = file_size - max_search
143
+ io.seek(search_offset, IO::SEEK_SET)
144
+ search_buf = io.read(max_search)
145
+ return nil unless search_buf
146
+
147
+ eocd_index = search_buf.rindex("PK\x05\x06")
148
+ return nil unless eocd_index
149
+
150
+ eocd_pos = search_offset + eocd_index
151
+ io.seek(eocd_pos + 4, IO::SEEK_SET)
152
+ eocd_data = io.read(18)
153
+ return nil unless eocd_data && eocd_data.bytesize == 18
154
+
155
+ _disk_num, _cd_disk, _disk_entries, total_entries, _cd_size, cd_offset, _comment_len = eocd_data.unpack("vvvvVVv")
156
+ return nil if cd_offset > file_size
157
+
158
+ io.seek(cd_offset, IO::SEEK_SET)
159
+ result = {}
160
+
161
+ total_entries.times do
162
+ sig = io.read(4)
163
+ break unless sig == "PK\x01\x02"
164
+
165
+ cd_header = io.read(42)
166
+ break unless cd_header && cd_header.bytesize == 42
167
+
168
+ _v_made, _v_need, _gp, method, _time, _date, _crc, csize, _usize, nlen, elen, clen, _dnum, _iattr, _eattr, offset = cd_header.unpack("vvvvvvVVVvvvvvVV")
169
+ entry_name = io.read(nlen).force_encoding("UTF-8")
170
+ io.read(elen + clen)
171
+
172
+ next if entry_name.end_with?("/")
173
+
174
+ current_cd_pos = io.pos
175
+
176
+ io.seek(offset, IO::SEEK_SET)
177
+ local_sig = io.read(4)
178
+ next unless local_sig == LOCAL_HEADER_SIG
179
+
180
+ local_header = io.read(26)
181
+ next unless local_header && local_header.bytesize == 26
182
+
183
+ local_nlen = local_header[22, 2].unpack1("v")
184
+ 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
190
+
191
+ io.seek(current_cd_pos, IO::SEEK_SET)
192
+ end
193
+
194
+ result
195
+ rescue ArgumentError => e
196
+ raise e
197
+ rescue StandardError
198
+ nil
199
+ end
200
+
201
+ def find_data_descriptor_stream(io, method)
112
202
  if method == 8
113
203
  inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
114
204
  result = +""
115
- consumed = 0
116
205
  chunk_size = 4096
117
- pos = offset
118
206
  begin
119
- while pos < data.bytesize
120
- chunk = data[pos, [chunk_size, data.bytesize - pos].min]
121
- break if chunk.nil? || chunk.empty?
207
+ while !inflater.finished? && (chunk = io.read(chunk_size))
208
+ break if chunk.empty?
209
+
210
+ inflated = inflater.inflate(chunk)
211
+ limit = @max_uncompressed_size || MAX_UNCOMPRESSED_SIZE
212
+ raise ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
122
213
 
123
- result << inflater.inflate(chunk)
124
- consumed += chunk.bytesize
125
- pos += chunk.bytesize
214
+ result << inflated
126
215
  end
127
216
  rescue Zlib::BufError, Zlib::DataError
128
- # Inflation ended — find actual consumed size
217
+ # Inflation ended
129
218
  ensure
130
- # Calculate actual compressed bytes consumed
131
- consumed -= inflater.avail_in
219
+ remaining_bytes = inflater.avail_in
132
220
  inflater.close
133
221
  end
134
- [result, consumed, result.bytesize]
222
+
223
+ io.seek(-remaining_bytes, IO::SEEK_CUR) if io.respond_to?(:seek) && remaining_bytes.positive?
224
+
225
+ desc_sig = io.read(4)
226
+ if desc_sig == [0x50, 0x4B, 0x07, 0x08].pack("C4")
227
+ io.read(12)
228
+ elsif desc_sig == [0x50, 0x4B, 0x03, 0x04].pack("C4") || desc_sig == [0x50, 0x4B, 0x01, 0x02].pack("C4")
229
+ io.seek(-4, IO::SEEK_CUR) if io.respond_to?(:seek)
230
+ elsif desc_sig
231
+ io.read(8)
232
+ end
233
+
234
+ [result, 0]
135
235
  else
136
- # Stored — scan for data descriptor signature or central directory
137
- # This is a fallback; stored + data descriptor is rare
138
- [data[offset, 0], 0, 0]
236
+ [io.read(0), 0]
139
237
  end
140
238
  end
141
239
 
@@ -143,15 +241,39 @@ module Xlsxrb
143
241
  return raw&.dup&.force_encoding("UTF-8") || "" if method.zero? # stored
144
242
 
145
243
  # Deflated
146
- Zlib::Inflate.inflate(-raw || "")
244
+ safe_inflate(raw || "", -Zlib::MAX_WBITS)
147
245
  rescue Zlib::DataError
148
246
  # Try with raw deflate (no header)
149
- inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
247
+ safe_inflate(raw || "", -Zlib::MAX_WBITS)
248
+ end
249
+
250
+ def safe_inflate(raw, wbits)
251
+ inflater = Zlib::Inflate.new(wbits)
252
+ result = +""
253
+ chunk_size = 32_768
254
+ offset = 0
255
+ raw_len = raw.bytesize
256
+ limit = @max_uncompressed_size || MAX_UNCOMPRESSED_SIZE
257
+
150
258
  begin
151
- inflater.inflate(raw || "")
259
+ while offset < raw_len
260
+ chunk = raw.byteslice(offset, chunk_size)
261
+ inflated = inflater.inflate(chunk)
262
+ raise ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
263
+
264
+ result << inflated
265
+ offset += chunk_size
266
+ end
267
+ # Finish inflation
268
+ inflated = inflater.finish
269
+ raise ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
270
+
271
+ result << inflated
152
272
  ensure
153
273
  inflater.close
154
274
  end
275
+
276
+ result.force_encoding("UTF-8")
155
277
  end
156
278
  end
157
279
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "zlib"
4
6
 
5
7
  module Xlsxrb
@@ -26,6 +28,8 @@ module Xlsxrb
26
28
  @io = io
27
29
  @entries = []
28
30
  @closed = false
31
+ @bytes_written = 0
32
+ @seekable = determine_seekable
29
33
  end
30
34
 
31
35
  # Add a file entry with string content.
@@ -49,17 +53,22 @@ module Xlsxrb
49
53
  def start_entry(path)
50
54
  raise "ZipWriter is closed" if @closed
51
55
 
56
+ entry_offset = @bytes_written
57
+ gp_flag = @seekable ? 0 : 0x0008
58
+
52
59
  @current_entry = {
53
60
  path: path,
54
- offset: @io.is_a?(StringIO) ? @io.pos : @io.tell,
61
+ offset: entry_offset,
55
62
  deflater: Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS),
56
63
  crc: Zlib.crc32,
57
64
  uncompressed_size: 0,
58
- compressed_size: 0
65
+ compressed_size: 0,
66
+ gp_flag: gp_flag,
67
+ seekable: @seekable
59
68
  }
60
69
 
61
- # Write a placeholder local header (will be patched later if IO supports seek)
62
- write_local_header(path, 0, 0, 0)
70
+ # Write local header (if unseekable, sizes/crc are 0 and bit 3 is set)
71
+ write_local_header(path, 0, 0, 0, gp_flag: gp_flag)
63
72
  end
64
73
 
65
74
  def write_data(str)
@@ -72,7 +81,7 @@ module Xlsxrb
72
81
  @current_entry[:uncompressed_size] += bytes.bytesize
73
82
  compressed = @current_entry[:deflater].deflate(bytes, Zlib::SYNC_FLUSH)
74
83
  @current_entry[:compressed_size] += compressed.bytesize
75
- @io.write(compressed)
84
+ write_bytes(compressed)
76
85
  end
77
86
 
78
87
  def finish_entry
@@ -84,17 +93,21 @@ module Xlsxrb
84
93
  # Flush remaining deflate data
85
94
  remaining = entry[:deflater].finish
86
95
  entry[:compressed_size] += remaining.bytesize
87
- @io.write(remaining)
96
+ write_bytes(remaining)
88
97
  entry[:deflater].close
89
98
 
90
99
  final_crc = entry[:crc] & 0xFFFFFFFF
91
100
 
92
- # Patch the local header if seekable
93
- current_pos = @io.is_a?(StringIO) ? @io.pos : @io.tell
94
- if @io.respond_to?(:seek)
101
+ if entry[:seekable]
102
+ # Patch the local header if seekable
103
+ current_pos = @io.is_a?(StringIO) ? @io.pos : @io.tell
95
104
  @io.seek(entry[:offset])
96
- write_local_header(entry[:path], final_crc, entry[:compressed_size], entry[:uncompressed_size])
105
+ write_local_header(entry[:path], final_crc, entry[:compressed_size], entry[:uncompressed_size], gp_flag: 0, count_bytes: false)
97
106
  @io.seek(current_pos)
107
+ else
108
+ # Write Data Descriptor (signature 0x08074b50 + crc32 + comp_size + uncomp_size)
109
+ descriptor = [0x08074B50, final_crc, entry[:compressed_size], entry[:uncompressed_size]].pack("VVVV")
110
+ write_bytes(descriptor)
98
111
  end
99
112
 
100
113
  @entries << {
@@ -102,7 +115,8 @@ module Xlsxrb
102
115
  crc32: final_crc,
103
116
  compressed_size: entry[:compressed_size],
104
117
  uncompressed_size: entry[:uncompressed_size],
105
- offset: entry[:offset]
118
+ offset: entry[:offset],
119
+ gp_flag: entry[:gp_flag]
106
120
  }
107
121
  end
108
122
 
@@ -112,28 +126,47 @@ module Xlsxrb
112
126
  finish_entry if @current_entry
113
127
 
114
128
  @closed = true
115
- cd_offset = @io.is_a?(StringIO) ? @io.pos : @io.tell
129
+ cd_offset = @bytes_written
116
130
  cd_size = write_central_directory
117
131
  write_end_of_central_directory(cd_offset, cd_size)
118
132
  end
119
133
 
120
134
  private
121
135
 
136
+ def determine_seekable
137
+ return true if @io.is_a?(StringIO)
138
+ return false unless @io.respond_to?(:seek) && @io.respond_to?(:tell)
139
+
140
+ begin
141
+ @io.tell
142
+ true
143
+ rescue SystemCallError, IOError
144
+ false
145
+ end
146
+ end
147
+
148
+ def write_bytes(data)
149
+ return if data.nil? || data.empty?
150
+
151
+ @io.write(data)
152
+ @bytes_written += data.bytesize
153
+ end
154
+
122
155
  def write_raw_entry(path, content_bytes)
123
156
  crc = Zlib.crc32(content_bytes) & 0xFFFFFFFF
124
157
  compressed = deflate(content_bytes)
158
+ offset = @bytes_written
125
159
 
126
- offset = @io.is_a?(StringIO) ? @io.pos : @io.tell
127
-
128
- write_local_header(path, crc, compressed.bytesize, content_bytes.bytesize)
129
- @io.write(compressed)
160
+ write_local_header(path, crc, compressed.bytesize, content_bytes.bytesize, gp_flag: 0)
161
+ write_bytes(compressed)
130
162
 
131
163
  @entries << {
132
164
  path: path,
133
165
  crc32: crc,
134
166
  compressed_size: compressed.bytesize,
135
167
  uncompressed_size: content_bytes.bytesize,
136
- offset: offset
168
+ offset: offset,
169
+ gp_flag: 0
137
170
  }
138
171
  end
139
172
 
@@ -146,12 +179,12 @@ module Xlsxrb
146
179
  end
147
180
  end
148
181
 
149
- def write_local_header(path, crc, compressed_size, uncompressed_size)
182
+ def write_local_header(path, crc, compressed_size, uncompressed_size, gp_flag: 0, count_bytes: true)
150
183
  name_bytes = path.encode("UTF-8").b
151
184
  header = [
152
185
  0x04034B50, # local file header signature
153
186
  20, # version needed (2.0)
154
- 0, # general purpose bit flag
187
+ gp_flag, # general purpose bit flag (0x0008 if data descriptor follows)
155
188
  8, # compression method (deflate)
156
189
  0, # last mod file time
157
190
  33, # last mod file date
@@ -161,8 +194,14 @@ module Xlsxrb
161
194
  name_bytes.bytesize,
162
195
  0 # extra field length
163
196
  ].pack("VvvvvvVVVvv")
164
- @io.write(header)
165
- @io.write(name_bytes)
197
+
198
+ if count_bytes
199
+ write_bytes(header)
200
+ write_bytes(name_bytes)
201
+ else
202
+ @io.write(header)
203
+ @io.write(name_bytes)
204
+ end
166
205
  end
167
206
 
168
207
  def write_central_directory
@@ -173,7 +212,7 @@ module Xlsxrb
173
212
  0x02014B50, # central directory file header signature
174
213
  20, # version made by
175
214
  20, # version needed
176
- 0, # general purpose bit flag
215
+ entry[:gp_flag] || 0, # general purpose bit flag
177
216
  8, # compression method
178
217
  0, # last mod file time
179
218
  33, # last mod file date
@@ -188,8 +227,9 @@ module Xlsxrb
188
227
  0, # external file attributes
189
228
  entry[:offset] # relative offset of local header
190
229
  ].pack("VvvvvvvVVVvvvvvVV")
191
- @io.write(header)
192
- @io.write(name_bytes)
230
+
231
+ write_bytes(header)
232
+ write_bytes(name_bytes)
193
233
  size += header.bytesize + name_bytes.bytesize
194
234
  end
195
235
  size
@@ -206,7 +246,7 @@ module Xlsxrb
206
246
  cd_offset, # offset of central directory
207
247
  0 # comment length
208
248
  ].pack("VvvvvVVv")
209
- @io.write(eocd)
249
+ write_bytes(eocd)
210
250
  end
211
251
  end
212
252
  end
data/lib/xlsxrb/ooxml.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require_relative "ooxml/utils"
4
6
  require_relative "ooxml/zip_reader"
5
7
  require_relative "ooxml/zip_writer"
@@ -17,6 +19,6 @@ module Xlsxrb
17
19
  # Handles ZIP extraction, SAX XML parsing, and XML generation
18
20
  # in strict accordance with ECMA-376.
19
21
  module Ooxml
20
- Event = Struct.new(:type, :args, :source)
22
+ Event = Data.define(:type, :args, :source)
21
23
  end
22
24
  end