manticore-smash 3.1.0 → 3.3.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.
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: false
2
+
3
+ # Copyright (C) 2024 Manticore Authors
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU Affero General Public License as published
7
+ # by the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU Affero General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU Affero General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ require 'stringio'
19
+ require 'zlib'
20
+
21
+ module XlsxKit
22
+ ##
23
+ # 最小化 ZIP32 读取器,专为 XLSX 解包设计。
24
+ #
25
+ # 不依赖 rubyzip 等第三方 gem,仅支持 XLSX 中出现的两种压缩方式:
26
+ # - DEFLATE (method 8): 通用压缩
27
+ # - STORED (method 0): 无压缩
28
+ #
29
+ # 实现思路:
30
+ # 1. 从文件末尾反向搜索 EOCD (End of Central Directory) 签名
31
+ # 2. 解析中央目录,获取所有条目的元数据与 Local Header 偏移
32
+ # 3. 按需读取单个条目:定位 Local Header → 跳过头部 → Zlib::Inflate 解压
33
+ #
34
+ # 与全量解包不同,本 reader 仅按 name 读取单个条目,避免将整个 ZIP 展开到内存或磁盘。
35
+
36
+ class ZipReader
37
+ # ZIP 格式签名(freeze 防止外部 << 修改)
38
+ EOCD_SIG = "PK\x05\x06".b.freeze # End of Central Directory
39
+ CDENTRY_SIG = "PK\x01\x02".b.freeze # Central Directory File Header
40
+ LOCAL_SIG = "PK\x03\x04".b.freeze # Local File Header
41
+
42
+ # 压缩方法
43
+ STORED = 0
44
+ DEFLATE = 8
45
+
46
+ attr_reader :entries
47
+
48
+ ##
49
+ # 初始化 ZIP 读取器。
50
+ #
51
+ # @param io [IO, String] 文件路径或已打开的 IO(必须可 seek)
52
+ def initialize(io)
53
+ @io = io.is_a?(String) ? File.open(io, 'rb') : io
54
+ @entries = nil
55
+ end
56
+
57
+ ##
58
+ # 便捷类方法:打开文件并读取后关闭。
59
+ def self.open(path)
60
+ reader = new(path)
61
+ yield reader
62
+ ensure
63
+ reader&.close
64
+ end
65
+
66
+ ##
67
+ # 关闭底层 IO(若由本类打开)。
68
+ def close
69
+ @io.close unless @io.closed?
70
+ end
71
+
72
+ ##
73
+ # 解析中央目录,返回 {name => entry} 的 Hash。
74
+ # entry 为包含 :name, :method, :compressed_size, :uncompressed_size, :local_offset 的 Hash。
75
+ def entries
76
+ return @entries if @entries
77
+
78
+ offset = find_eocd
79
+ @io.seek(offset)
80
+ sig = @io.read(4)
81
+ raise "EOCD signature not found" unless sig == EOCD_SIG
82
+
83
+ _disk_num, _ent_disk, _ent_here, total_ent,
84
+ _cd_size, cd_offset = @io.read(16).unpack('vvvvVV')
85
+
86
+ @io.seek(cd_offset)
87
+ @entries = {}
88
+
89
+ total_ent.times do
90
+ sig = @io.read(4)
91
+ break unless sig == CDENTRY_SIG
92
+
93
+ _ver_made, _ver_need, _flags, method,
94
+ _mtime, _mdate, crc32, comp_size, uncomp_size,
95
+ name_len, extra_len, comm_len,
96
+ _disk, _iattr, _eattr, local_offset = @io.read(42).unpack('vvvvvvVVVvvvvvVV')
97
+
98
+ name = @io.read(name_len).force_encoding('UTF-8')
99
+ @io.read(extra_len + comm_len) # 跳过 extra field 与 comment
100
+
101
+ @entries[name] = {
102
+ name: name,
103
+ method: method,
104
+ compressed_size: comp_size,
105
+ uncompressed_size: uncomp_size,
106
+ crc32: crc32,
107
+ local_offset: local_offset
108
+ }
109
+ end
110
+
111
+ @entries
112
+ end
113
+
114
+ ##
115
+ # 读取指定条目的原始字节(已解压)。
116
+ #
117
+ # @param name [String] 条目名(如 "xl/sharedStrings.xml")
118
+ # @return [String, nil] 二进制内容;条目不存在时返回 nil
119
+ def read_entry(name)
120
+ entry = entries[name]
121
+ return nil unless entry
122
+
123
+ data_offset = resolve_data_offset(entry[:local_offset])
124
+ @io.seek(data_offset)
125
+ raw = @io.read(entry[:compressed_size])
126
+
127
+ case entry[:method]
128
+ when STORED
129
+ raw
130
+ when DEFLATE
131
+ Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(raw)
132
+ else
133
+ raise "Unsupported compression method: #{entry[:method]}"
134
+ end
135
+ end
136
+
137
+ ##
138
+ # 读取指定条目并强制 UTF-8 编码(用于 XML 文本)。
139
+ def read_entry_utf8(name)
140
+ data = read_entry(name)
141
+ return nil unless data
142
+ data.force_encoding('UTF-8')
143
+ end
144
+
145
+ private
146
+
147
+ ##
148
+ # 从文件末尾反向搜索 EOCD 签名。
149
+ # EOCD 最小 22 字节,最大 22 + 65535(comment 区段),从尾部向前扫。
150
+ def find_eocd
151
+ size = @io.size
152
+ scan_size = [size, 65557].min # 22 + 65535
153
+ @io.seek(size - scan_size)
154
+ tail = @io.read(scan_size)
155
+ tail = tail.force_encoding('BINARY')
156
+
157
+ pat = EOCD_SIG.b # dup 避免修改 frozen 常量
158
+ pos = tail.rindex(pat)
159
+ raise "EOCD record not found — not a valid ZIP file" unless pos
160
+
161
+ size - scan_size + pos
162
+ end
163
+
164
+ ##
165
+ # 解析 Local File Header,返回实际数据起始偏移。
166
+ def resolve_data_offset(local_offset)
167
+ @io.seek(local_offset)
168
+ sig = @io.read(4)
169
+ raise "Local file header signature not found at #{local_offset}" unless sig == LOCAL_SIG
170
+
171
+ _ver, _flags, _method, _mtime, _mdate,
172
+ _crc, _comp, _uncomp, name_len, extra_len = @io.read(26).unpack('vvvvvVVVvv')
173
+
174
+ local_offset + 30 + name_len + extra_len
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: false
2
+
3
+ # Copyright (C) 2024 Manticore Authors
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU Affero General Public License as published
7
+ # by the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU Affero General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU Affero General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ require 'zlib'
19
+
20
+ module XlsxKit
21
+ ##
22
+ # 最小化 ZIP32 写入器,专为 XLSX 打包设计。
23
+ #
24
+ # 与 ZipReader 对应,不依赖 rubyzip,仅支持写入 XLSX 所需的两种压缩方式:
25
+ # - DEFLATE (method 8): 通用压缩
26
+ # - STORED (method 0): 无压缩
27
+ #
28
+ # 实现思路:
29
+ # 1. add_entry 时即时写出 Local File Header + 数据,并记录偏移
30
+ # 2. 同时在内存累积 Central Directory 记录
31
+ # 3. close 时写出 Central Directory + EOCD 收尾
32
+ #
33
+ # 边写边压,避免将整个 ZIP 缓冲在内存或磁盘。
34
+
35
+ class ZipWriter
36
+ # ZIP 格式签名
37
+ LOCAL_SIG = "PK\x03\x04".b.freeze # Local File Header
38
+ CDENTRY_SIG = "PK\x01\x02".b.freeze # Central Directory File Header
39
+ EOCD_SIG = "PK\x05\x06".b.freeze # End of Central Directory
40
+
41
+ # 压缩方法
42
+ STORED = 0
43
+ DEFLATE = 8
44
+
45
+ ##
46
+ # 初始化写入器。
47
+ #
48
+ # @param io [IO] 可写 IO(必须已以二进制模式打开)
49
+ def initialize(io)
50
+ @io = io
51
+ @offset = 0 # 当前数据起始偏移(用于 Central Directory 定位)
52
+ @count = 0 # 条目数
53
+ @central = ''.b # 中央目录累积缓冲
54
+ end
55
+
56
+ ##
57
+ # 便捷类方法:写入文件路径,块结束后自动收尾并关闭。
58
+ def self.open(path)
59
+ File.open(path, 'wb') do |f|
60
+ writer = new(f)
61
+ yield writer
62
+ writer.close
63
+ end
64
+ end
65
+
66
+ ##
67
+ # 添加一个条目(文件),立即写出数据。
68
+ #
69
+ # @param name [String] 条目名(如 "xl/worksheets/sheet1.xml")
70
+ # @param data [String] 内容(UTF-8 字符串或二进制)
71
+ # @param method [Integer] 压缩方式(DEFLATE / STORED)
72
+ def add_entry(name, data, method: DEFLATE)
73
+ name_bytes = name.b
74
+ data_bytes = data.b
75
+ crc = Zlib.crc32(data_bytes)
76
+
77
+ compressed =
78
+ case method
79
+ when STORED then data_bytes
80
+ when DEFLATE then Zlib::Deflate.new(nil, -Zlib::MAX_WBITS).deflate(data_bytes, Zlib::FINISH)
81
+ else raise ArgumentError, "unsupported compression method: #{method}"
82
+ end
83
+
84
+ # Local File Header (30 bytes + name + data)
85
+ local = LOCAL_SIG.dup
86
+ local << [20, 0, method, 0, 0, crc,
87
+ compressed.bytesize, data_bytes.bytesize,
88
+ name_bytes.bytesize, 0].pack('vvvvvVVVvv')
89
+ local << name_bytes
90
+ local << compressed
91
+ @io.write(local)
92
+
93
+ # Central Directory Record (46 bytes + name)
94
+ cd = CDENTRY_SIG.dup
95
+ cd << [20, 20, 0, method, 0, 0, crc,
96
+ compressed.bytesize, data_bytes.bytesize,
97
+ name_bytes.bytesize, 0, 0, 0, 0, 0, @offset].pack('vvvvvvVVVvvvvvVV')
98
+ cd << name_bytes
99
+ @central << cd
100
+
101
+ @offset += local.bytesize
102
+ @count += 1
103
+ self
104
+ end
105
+
106
+ ##
107
+ # 写出 Central Directory 与 EOCD,收尾 ZIP 文件。
108
+ def close
109
+ cd_offset = @offset
110
+ @io.write(@central)
111
+
112
+ eocd = EOCD_SIG.dup
113
+ eocd << [0, 0, @count, @count, @central.bytesize, cd_offset, 0].pack('vvvvVVv')
114
+ @io.write(eocd)
115
+ @io
116
+ end
117
+ end
118
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: manticore-smash
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frampt
@@ -18,6 +18,11 @@ files:
18
18
  - README.md
19
19
  - lib/manticore.rb
20
20
  - lib/mdutils/rediscount.rb
21
+ - lib/xlsxkit/sax_parser.rb
22
+ - lib/xlsxkit/workbook.rb
23
+ - lib/xlsxkit/writer.rb
24
+ - lib/xlsxkit/zip_reader.rb
25
+ - lib/xlsxkit/zip_writer.rb
21
26
  - lib/xmlutils/formatters.rb
22
27
  - lib/xmlutils/node.rb
23
28
  - lib/xmlutils/tokenizer.rb
@@ -42,7 +47,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
42
47
  - !ruby/object:Gem::Version
43
48
  version: '0'
44
49
  requirements: []
45
- rubygems_version: 4.0.10
50
+ rubygems_version: 4.0.19
46
51
  specification_version: 4
47
52
  summary: A multi-format toolkit
48
53
  test_files: []