xlsxrb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +64 -0
  3. data/.devcontainer/devcontainer.json +17 -0
  4. data/CHANGELOG.md +12 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +384 -0
  8. data/Rakefile +290 -0
  9. data/benchmark.rb +390 -0
  10. data/docs/ARCHITECTURE.md +488 -0
  11. data/docs/SPEC_SOURCES.md +42 -0
  12. data/docs/visual/VisualGallery.md +4684 -0
  13. data/docs/wasm/wasm_doc_helper.css +189 -0
  14. data/docs/wasm/wasm_doc_helper.js +320 -0
  15. data/lib/xlsxrb/elements/cell.rb +77 -0
  16. data/lib/xlsxrb/elements/column.rb +28 -0
  17. data/lib/xlsxrb/elements/row.rb +47 -0
  18. data/lib/xlsxrb/elements/types.rb +36 -0
  19. data/lib/xlsxrb/elements/workbook.rb +47 -0
  20. data/lib/xlsxrb/elements/worksheet.rb +50 -0
  21. data/lib/xlsxrb/elements.rb +15 -0
  22. data/lib/xlsxrb/ooxml/reader.rb +8048 -0
  23. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
  24. data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
  25. data/lib/xlsxrb/ooxml/utils.rb +122 -0
  26. data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
  27. data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
  28. data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
  29. data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
  30. data/lib/xlsxrb/ooxml/writer.rb +5450 -0
  31. data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
  32. data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
  33. data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
  34. data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
  35. data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
  36. data/lib/xlsxrb/ooxml.rb +22 -0
  37. data/lib/xlsxrb/style_builder.rb +241 -0
  38. data/lib/xlsxrb/version.rb +5 -0
  39. data/lib/xlsxrb.rb +1427 -0
  40. data/measure_memory.rb +42 -0
  41. data/sig/xlsxrb.rbs +23 -0
  42. data/vendor/sdk_runner/Program.cs +91 -0
  43. data/vendor/sdk_runner/sdk_runner.csproj +13 -0
  44. metadata +144 -0
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xlsxrb
4
+ module Ooxml
5
+ # Streams well-formed XML to a writable IO without building a DOM.
6
+ class XmlBuilder
7
+ XML_HEADER = %(<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n)
8
+
9
+ ESCAPE_MAP = {
10
+ "&" => "&amp;",
11
+ "<" => "&lt;",
12
+ ">" => "&gt;",
13
+ '"' => "&quot;",
14
+ "'" => "&apos;"
15
+ }.freeze
16
+
17
+ ESCAPE_RE = /[&<>"']/
18
+
19
+ def initialize(io)
20
+ @io = io
21
+ end
22
+
23
+ def declaration
24
+ @io << XML_HEADER
25
+ self
26
+ end
27
+
28
+ # Opens a tag, yields for children, then closes the tag.
29
+ def tag(name, attrs = {}, &block)
30
+ if block
31
+ open_tag(name, attrs)
32
+ yield self
33
+ close_tag(name)
34
+ else
35
+ empty_tag(name, attrs)
36
+ end
37
+ self
38
+ end
39
+
40
+ def open_tag(name, attrs = {})
41
+ @io << "<#{name}"
42
+ write_attrs(attrs)
43
+ @io << ">"
44
+ self
45
+ end
46
+
47
+ def close_tag(name)
48
+ @io << "</#{name}>"
49
+ self
50
+ end
51
+
52
+ def empty_tag(name, attrs = {})
53
+ @io << "<#{name}"
54
+ write_attrs(attrs)
55
+ @io << "/>"
56
+ self
57
+ end
58
+
59
+ def text(content)
60
+ @io << escape(content.to_s)
61
+ self
62
+ end
63
+
64
+ # Write raw XML string (for unmapped_data restoration).
65
+ def raw(xml_string)
66
+ @io << xml_string
67
+ self
68
+ end
69
+
70
+ # Serialize an unmapped_data hash back to XML.
71
+ def write_unmapped(node)
72
+ return unless node.is_a?(Hash) && node[:tag]
73
+
74
+ tag_name = node[:tag]
75
+ attrs = node[:attrs] || {}
76
+ children = node[:children] || []
77
+ text_content = node[:text]
78
+
79
+ if children.empty? && (text_content.nil? || text_content.empty?)
80
+ empty_tag(tag_name, attrs)
81
+ else
82
+ open_tag(tag_name, attrs)
83
+ text(text_content) if text_content && !text_content.empty?
84
+ children.each { |child| write_unmapped(child) }
85
+ close_tag(tag_name)
86
+ end
87
+ end
88
+
89
+ def to_s
90
+ @io.is_a?(StringIO) ? @io.string : @io.to_s
91
+ end
92
+
93
+ private
94
+
95
+ def write_attrs(attrs)
96
+ attrs.each do |k, v|
97
+ next if v.nil?
98
+
99
+ @io.write(" ")
100
+ @io.write(k.to_s)
101
+ @io.write('="')
102
+ @io.write(escape(v))
103
+ @io.write('"')
104
+ end
105
+ end
106
+
107
+ def escape(value)
108
+ str = value.to_s
109
+ str.match?(ESCAPE_RE) ? str.gsub(ESCAPE_RE, ESCAPE_MAP) : str
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/parsers/sax2parser"
4
+ require "rexml/sax2listener"
5
+
6
+ module Xlsxrb
7
+ module Ooxml
8
+ # Thin wrapper around REXML SAX2 parser for streaming XML processing.
9
+ # Unknown elements are captured as unmapped data hashes.
10
+ module XmlParser
11
+ # Parses XML string using SAX2, calling the given listener.
12
+ def self.parse(xml_string, listener)
13
+ parser = REXML::Parsers::SAX2Parser.new(xml_string)
14
+ parser.listen(listener)
15
+ parser.parse
16
+ end
17
+
18
+ # Base listener with unmapped-data collection support.
19
+ class BaseListener
20
+ include REXML::SAX2Listener
21
+
22
+ attr_reader :unmapped_data
23
+
24
+ def initialize
25
+ @unmapped_data = []
26
+ @unmapped_stack = []
27
+ @capturing_unmapped = false
28
+ @current_unmapped = nil
29
+ end
30
+
31
+ # Override in subclass: return true if the tag is recognized.
32
+ def recognized_tag?(_uri, _localname, _qname)
33
+ false
34
+ end
35
+
36
+ def start_element(uri, localname, qname, attrs)
37
+ if @capturing_unmapped
38
+ child = { tag: localname, attrs: attrs.dup, children: [], text: nil }
39
+ @unmapped_stack.last[:children] << child
40
+ @unmapped_stack.push(child)
41
+ elsif !recognized_tag?(uri, localname, qname)
42
+ @capturing_unmapped = true
43
+ @current_unmapped = { tag: localname, attrs: attrs.dup, children: [], text: nil }
44
+ @unmapped_stack.push(@current_unmapped)
45
+ end
46
+ end
47
+
48
+ def end_element(_uri, _localname, _qname)
49
+ return unless @capturing_unmapped
50
+
51
+ @unmapped_stack.pop
52
+ return unless @unmapped_stack.empty?
53
+
54
+ @capturing_unmapped = false
55
+ @unmapped_data << @current_unmapped
56
+ @current_unmapped = nil
57
+ end
58
+
59
+ def characters(text)
60
+ return unless @capturing_unmapped && !@unmapped_stack.empty?
61
+
62
+ current = @unmapped_stack.last
63
+ current[:text] = (current[:text] || "") + text
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Xlsxrb
6
+ module Ooxml
7
+ # Generates a simple ZIP file using only the standard library.
8
+ class ZipGenerator
9
+ def initialize(filepath)
10
+ @filepath = filepath
11
+ @entries = []
12
+ @file = nil
13
+ end
14
+
15
+ # Adds an entry to the ZIP archive.
16
+ def add_entry(filepath, content)
17
+ compressed_data = compress(content)
18
+ @entries << {
19
+ path: filepath,
20
+ content: content,
21
+ compressed_data: compressed_data,
22
+ crc32: crc32(content),
23
+ uncompressed_size: content.bytesize,
24
+ compressed_size: compressed_data.bytesize,
25
+ mtime: Time.now
26
+ }
27
+ end
28
+
29
+ # Generates the ZIP file.
30
+ def generate
31
+ File.open(@filepath, "wb") do |f|
32
+ @file = f
33
+ write_entries
34
+ write_central_directory
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def write_entries
41
+ @entries.each do |entry|
42
+ write_local_header(entry)
43
+ write_file_data(entry)
44
+ end
45
+ end
46
+
47
+ def write_local_header(entry)
48
+ filename = entry[:path]
49
+ filename_bytes = filename.bytes
50
+
51
+ # Local file header
52
+ local_header = []
53
+ local_header += [0x50, 0x4b, 0x03, 0x04] # Signature
54
+ local_header += [0x14, 0x00] # Version needed to extract
55
+ local_header += [0x00, 0x00] # General purpose bit flag
56
+ local_header += [0x08, 0x00] # Compression method (8 = deflate)
57
+
58
+ # Last mod file time/date
59
+ dos_time = dos_datetime(entry[:mtime])
60
+ local_header += dos_time
61
+
62
+ local_header += le32(entry[:crc32]) # CRC-32
63
+ local_header += le32(entry[:compressed_size]) # Compressed size
64
+ local_header += le32(entry[:uncompressed_size]) # Uncompressed size
65
+ local_header += le16(filename_bytes.length) # Filename length
66
+ local_header += le16(0) # Extra field length
67
+
68
+ @file.write(local_header.pack("C*"))
69
+ @file.write(filename)
70
+ end
71
+
72
+ def write_file_data(entry)
73
+ @file.write(entry[:compressed_data])
74
+ end
75
+
76
+ def write_central_directory
77
+ offset = @file.pos
78
+ cd_offset = 0
79
+ @entries.each do |entry|
80
+ filename = entry[:path]
81
+ filename_bytes = filename.bytes
82
+
83
+ cd_header = []
84
+ cd_header += [0x50, 0x4b, 0x01, 0x02] # Central directory file header signature
85
+ cd_header += [0x14, 0x03] # Version made by
86
+ cd_header += [0x14, 0x00] # Version needed to extract
87
+ cd_header += [0x00, 0x00] # General purpose bit flag
88
+ cd_header += [0x08, 0x00] # Compression method
89
+
90
+ dos_time = dos_datetime(entry[:mtime])
91
+ cd_header += dos_time
92
+
93
+ cd_header += le32(entry[:crc32]) # CRC-32
94
+ cd_header += le32(entry[:compressed_size]) # Compressed size
95
+ cd_header += le32(entry[:uncompressed_size]) # Uncompressed size
96
+ cd_header += le16(filename_bytes.length) # Filename length
97
+ cd_header += le16(0) # Extra field length
98
+ cd_header += le16(0) # File comment length
99
+ cd_header += le16(0) # Disk number start
100
+ cd_header += le16(0) # Internal file attributes
101
+ cd_header += le32(0) # External file attributes
102
+ cd_header += le32(cd_offset) # Local header offset
103
+
104
+ @file.write(cd_header.pack("C*"))
105
+ @file.write(filename)
106
+
107
+ # Advance offset past the local header and compressed data for this entry.
108
+ cd_offset += 30 + filename_bytes.length + entry[:compressed_size]
109
+ end
110
+
111
+ # End of central directory record
112
+ end_cd = []
113
+ end_cd += [0x50, 0x4b, 0x05, 0x06] # End of central directory signature
114
+ end_cd += le16(0) # Disk number
115
+ end_cd += le16(0) # Disk with central directory
116
+ end_cd += le16(@entries.length) # Number of central directory records on this disk
117
+ end_cd += le16(@entries.length) # Total number of central directory records
118
+ end_cd += le32(calculate_central_dir_size) # Size of central directory
119
+ end_cd += le32(offset) # Offset of central directory
120
+ end_cd += le16(0) # Comment length
121
+
122
+ @file.write(end_cd.pack("C*"))
123
+ end
124
+
125
+ def compress(content)
126
+ deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
127
+ begin
128
+ deflater.deflate(content, Zlib::FINISH)
129
+ ensure
130
+ deflater.close
131
+ end
132
+ end
133
+
134
+ def crc32(content)
135
+ Zlib.crc32(content) & 0xFFFFFFFF
136
+ end
137
+
138
+ def dos_datetime(time)
139
+ dos_date = ((time.year - 1980) << 9) | (time.month << 5) | time.day
140
+ dos_time = (time.hour << 11) | (time.min << 5) | (time.sec / 2)
141
+ le16(dos_time) + le16(dos_date)
142
+ end
143
+
144
+ def le16(value)
145
+ [(value & 0xFF), ((value >> 8) & 0xFF)]
146
+ end
147
+
148
+ def le32(value)
149
+ [(value & 0xFF), ((value >> 8) & 0xFF), ((value >> 16) & 0xFF), ((value >> 24) & 0xFF)]
150
+ end
151
+
152
+ 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
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "stringio"
5
+
6
+ module Xlsxrb
7
+ module Ooxml
8
+ # Reads ZIP archives using only stdlib (zlib).
9
+ # Scans local file headers sequentially — works with non-seekable IO.
10
+ class ZipReader
11
+ LOCAL_HEADER_SIG = [0x50, 0x4B, 0x03, 0x04].pack("C4")
12
+
13
+ # Opens a ZIP from a file path or IO and yields the reader.
14
+ def self.open(source)
15
+ io = source.is_a?(String) ? File.open(source, "rb") : source
16
+ reader = new(io)
17
+ if block_given?
18
+ begin
19
+ yield reader
20
+ ensure
21
+ io.close if source.is_a?(String)
22
+ end
23
+ else
24
+ reader
25
+ end
26
+ end
27
+
28
+ def initialize(io)
29
+ @io = io
30
+ @entries = nil
31
+ end
32
+
33
+ # Returns a Hash { entry_name => raw_bytes } for all entries.
34
+ def read_all
35
+ result = {}
36
+ each_entry { |name, data| result[name] = data }
37
+ result
38
+ end
39
+
40
+ # Returns raw bytes for a single entry, or nil if not found.
41
+ def read_entry(name)
42
+ entries[name]
43
+ end
44
+
45
+ # Yields (entry_name, data_string) for each file in the archive.
46
+ def each_entry(&block)
47
+ return enum_for(:each_entry) unless block
48
+
49
+ entries.each_pair(&block)
50
+ end
51
+
52
+ private
53
+
54
+ def entries
55
+ @entries ||= parse_entries
56
+ end
57
+
58
+ def parse_entries
59
+ result = {}
60
+ data = @io.is_a?(StringIO) ? @io.string : @io.read
61
+ data = data.b
62
+ pos = 0
63
+
64
+ while pos + 4 <= data.bytesize
65
+ sig = data[pos, 4]
66
+ break unless sig == LOCAL_HEADER_SIG
67
+
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
71
+
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")
79
+
80
+ entry_name = data[pos + 30, name_len].force_encoding("UTF-8")
81
+ file_data_offset = pos + 30 + name_len + extra_len
82
+
83
+ # Data descriptor present if bit 3 of gp_flag is set
84
+ has_data_descriptor = gp_flag.anybits?(0x08)
85
+
86
+ 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
99
+ else
100
+ raw = data[file_data_offset, compressed_size]
101
+ entry_data = decompress(raw, method)
102
+ result[entry_name] = entry_data unless entry_name.end_with?("/")
103
+ pos = file_data_offset + compressed_size
104
+ end
105
+ end
106
+
107
+ result
108
+ end
109
+
110
+ def find_data_descriptor(data, offset, method)
111
+ # For deflated data, we inflate to find the end
112
+ if method == 8
113
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
114
+ result = +""
115
+ consumed = 0
116
+ chunk_size = 4096
117
+ pos = offset
118
+ begin
119
+ while pos < data.bytesize
120
+ chunk = data[pos, [chunk_size, data.bytesize - pos].min]
121
+ break if chunk.nil? || chunk.empty?
122
+
123
+ result << inflater.inflate(chunk)
124
+ consumed += chunk.bytesize
125
+ pos += chunk.bytesize
126
+ end
127
+ rescue Zlib::BufError, Zlib::DataError
128
+ # Inflation ended — find actual consumed size
129
+ ensure
130
+ # Calculate actual compressed bytes consumed
131
+ consumed -= inflater.avail_in
132
+ inflater.close
133
+ end
134
+ [result, consumed, result.bytesize]
135
+ 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]
139
+ end
140
+ end
141
+
142
+ def decompress(raw, method)
143
+ return raw&.dup&.force_encoding("UTF-8") || "" if method.zero? # stored
144
+
145
+ # Deflated
146
+ Zlib::Inflate.inflate(-raw || "")
147
+ rescue Zlib::DataError
148
+ # Try with raw deflate (no header)
149
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
150
+ begin
151
+ inflater.inflate(raw || "")
152
+ ensure
153
+ inflater.close
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Xlsxrb
6
+ module Ooxml
7
+ # Writes ZIP archives using only stdlib (zlib).
8
+ # Supports streaming: entries are written sequentially, central directory at close.
9
+ class ZipWriter
10
+ def self.open(target, &block)
11
+ io = target.is_a?(String) ? File.open(target, "wb") : target
12
+ writer = new(io)
13
+ if block
14
+ begin
15
+ yield writer
16
+ ensure
17
+ writer.close
18
+ io.close if target.is_a?(String)
19
+ end
20
+ else
21
+ writer
22
+ end
23
+ end
24
+
25
+ def initialize(io)
26
+ @io = io
27
+ @entries = []
28
+ @closed = false
29
+ end
30
+
31
+ # Add a file entry with string content.
32
+ def add_entry(path, content)
33
+ raise "ZipWriter is closed" if @closed
34
+
35
+ content_bytes = content.encode("UTF-8").b
36
+ write_raw_entry(path, content_bytes)
37
+ end
38
+
39
+ # Add a file entry with binary content (no encoding conversion).
40
+ def add_binary_entry(path, content)
41
+ raise "ZipWriter is closed" if @closed
42
+
43
+ content_bytes = content.b
44
+ write_raw_entry(path, content_bytes)
45
+ end
46
+
47
+ # Write a string directly into the current ZIP entry stream.
48
+ # Use start_entry / write_data / finish_entry for true streaming.
49
+ def start_entry(path)
50
+ raise "ZipWriter is closed" if @closed
51
+
52
+ @current_entry = {
53
+ path: path,
54
+ offset: @io.is_a?(StringIO) ? @io.pos : @io.tell,
55
+ deflater: Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS),
56
+ crc: Zlib.crc32,
57
+ uncompressed_size: 0,
58
+ compressed_size: 0
59
+ }
60
+
61
+ # Write a placeholder local header (will be patched later if IO supports seek)
62
+ write_local_header(path, 0, 0, 0)
63
+ end
64
+
65
+ def write_data(str)
66
+ raise "No entry started" unless @current_entry
67
+
68
+ bytes = str.b
69
+ return if bytes.empty?
70
+
71
+ @current_entry[:crc] = Zlib.crc32(bytes, @current_entry[:crc])
72
+ @current_entry[:uncompressed_size] += bytes.bytesize
73
+ compressed = @current_entry[:deflater].deflate(bytes, Zlib::SYNC_FLUSH)
74
+ @current_entry[:compressed_size] += compressed.bytesize
75
+ @io.write(compressed)
76
+ end
77
+
78
+ def finish_entry
79
+ raise "No entry started" unless @current_entry
80
+
81
+ entry = @current_entry
82
+ @current_entry = nil
83
+
84
+ # Flush remaining deflate data
85
+ remaining = entry[:deflater].finish
86
+ entry[:compressed_size] += remaining.bytesize
87
+ @io.write(remaining)
88
+ entry[:deflater].close
89
+
90
+ final_crc = entry[:crc] & 0xFFFFFFFF
91
+
92
+ # Patch the local header if seekable
93
+ current_pos = @io.is_a?(StringIO) ? @io.pos : @io.tell
94
+ if @io.respond_to?(:seek)
95
+ @io.seek(entry[:offset])
96
+ write_local_header(entry[:path], final_crc, entry[:compressed_size], entry[:uncompressed_size])
97
+ @io.seek(current_pos)
98
+ end
99
+
100
+ @entries << {
101
+ path: entry[:path],
102
+ crc32: final_crc,
103
+ compressed_size: entry[:compressed_size],
104
+ uncompressed_size: entry[:uncompressed_size],
105
+ offset: entry[:offset]
106
+ }
107
+ end
108
+
109
+ def close
110
+ return if @closed
111
+
112
+ finish_entry if @current_entry
113
+
114
+ @closed = true
115
+ cd_offset = @io.is_a?(StringIO) ? @io.pos : @io.tell
116
+ cd_size = write_central_directory
117
+ write_end_of_central_directory(cd_offset, cd_size)
118
+ end
119
+
120
+ private
121
+
122
+ def write_raw_entry(path, content_bytes)
123
+ crc = Zlib.crc32(content_bytes) & 0xFFFFFFFF
124
+ compressed = deflate(content_bytes)
125
+
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)
130
+
131
+ @entries << {
132
+ path: path,
133
+ crc32: crc,
134
+ compressed_size: compressed.bytesize,
135
+ uncompressed_size: content_bytes.bytesize,
136
+ offset: offset
137
+ }
138
+ end
139
+
140
+ def deflate(content)
141
+ deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
142
+ begin
143
+ deflater.deflate(content, Zlib::FINISH)
144
+ ensure
145
+ deflater.close
146
+ end
147
+ end
148
+
149
+ def write_local_header(path, crc, compressed_size, uncompressed_size)
150
+ name_bytes = path.encode("UTF-8").b
151
+ header = [
152
+ 0x04034B50, # local file header signature
153
+ 20, # version needed (2.0)
154
+ 0, # general purpose bit flag
155
+ 8, # compression method (deflate)
156
+ 0, # last mod file time
157
+ 33, # last mod file date
158
+ crc,
159
+ compressed_size,
160
+ uncompressed_size,
161
+ name_bytes.bytesize,
162
+ 0 # extra field length
163
+ ].pack("VvvvvvVVVvv")
164
+ @io.write(header)
165
+ @io.write(name_bytes)
166
+ end
167
+
168
+ def write_central_directory
169
+ size = 0
170
+ @entries.each do |entry|
171
+ name_bytes = entry[:path].encode("UTF-8").b
172
+ header = [
173
+ 0x02014B50, # central directory file header signature
174
+ 20, # version made by
175
+ 20, # version needed
176
+ 0, # general purpose bit flag
177
+ 8, # compression method
178
+ 0, # last mod file time
179
+ 33, # last mod file date
180
+ entry[:crc32],
181
+ entry[:compressed_size],
182
+ entry[:uncompressed_size],
183
+ name_bytes.bytesize,
184
+ 0, # extra field length
185
+ 0, # file comment length
186
+ 0, # disk number start
187
+ 0, # internal file attributes
188
+ 0, # external file attributes
189
+ entry[:offset] # relative offset of local header
190
+ ].pack("VvvvvvvVVVvvvvvVV")
191
+ @io.write(header)
192
+ @io.write(name_bytes)
193
+ size += header.bytesize + name_bytes.bytesize
194
+ end
195
+ size
196
+ end
197
+
198
+ def write_end_of_central_directory(cd_offset, cd_size)
199
+ eocd = [
200
+ 0x06054B50, # end of central dir signature
201
+ 0, # disk number
202
+ 0, # disk with central directory
203
+ @entries.size, # entries on this disk
204
+ @entries.size, # total entries
205
+ cd_size, # size of central directory
206
+ cd_offset, # offset of central directory
207
+ 0 # comment length
208
+ ].pack("VvvvvVVv")
209
+ @io.write(eocd)
210
+ end
211
+ end
212
+ end
213
+ end