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.
- checksums.yaml +7 -0
- data/.devcontainer/Dockerfile +64 -0
- data/.devcontainer/devcontainer.json +17 -0
- data/CHANGELOG.md +12 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +290 -0
- data/benchmark.rb +390 -0
- data/docs/ARCHITECTURE.md +488 -0
- data/docs/SPEC_SOURCES.md +42 -0
- data/docs/visual/VisualGallery.md +4684 -0
- data/docs/wasm/wasm_doc_helper.css +189 -0
- data/docs/wasm/wasm_doc_helper.js +320 -0
- data/lib/xlsxrb/elements/cell.rb +77 -0
- data/lib/xlsxrb/elements/column.rb +28 -0
- data/lib/xlsxrb/elements/row.rb +47 -0
- data/lib/xlsxrb/elements/types.rb +36 -0
- data/lib/xlsxrb/elements/workbook.rb +47 -0
- data/lib/xlsxrb/elements/worksheet.rb +50 -0
- data/lib/xlsxrb/elements.rb +15 -0
- data/lib/xlsxrb/ooxml/reader.rb +8048 -0
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
- data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
- data/lib/xlsxrb/ooxml/utils.rb +122 -0
- data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
- data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
- data/lib/xlsxrb/ooxml/writer.rb +5450 -0
- data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
- data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
- data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
- data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
- data/lib/xlsxrb/ooxml.rb +22 -0
- data/lib/xlsxrb/style_builder.rb +241 -0
- data/lib/xlsxrb/version.rb +5 -0
- data/lib/xlsxrb.rb +1427 -0
- data/measure_memory.rb +42 -0
- data/sig/xlsxrb.rbs +23 -0
- data/vendor/sdk_runner/Program.cs +91 -0
- data/vendor/sdk_runner/sdk_runner.csproj +13 -0
- 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
|
+
"&" => "&",
|
|
11
|
+
"<" => "<",
|
|
12
|
+
">" => ">",
|
|
13
|
+
'"' => """,
|
|
14
|
+
"'" => "'"
|
|
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
|