manticore-smash 3.2.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.
data/lib/manticore.rb CHANGED
@@ -23,5 +23,7 @@ require_relative 'xmlutils/formatters'
23
23
  require_relative 'xmlutils/xml_doc'
24
24
  require_relative 'mdutils/rediscount'
25
25
  require_relative 'xlsxkit/zip_reader'
26
+ require_relative 'xlsxkit/zip_writer'
26
27
  require_relative 'xlsxkit/sax_parser'
27
28
  require_relative 'xlsxkit/workbook'
29
+ require_relative 'xlsxkit/writer'
@@ -0,0 +1,279 @@
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_relative 'zip_writer'
19
+
20
+ module XlsxKit
21
+ ##
22
+ # XLSX Workbook 写入 API(与 Workbook 读取器对应)。
23
+ #
24
+ # 将原生 Ruby 数据(二维数组 / Hash 数组 / 多 Sheet)按表格写入 .xlsx 文件。
25
+ #
26
+ # 用法:
27
+ # # 1. 一次性写入单个 Sheet
28
+ # XlsxKit::Writer.write('out.xlsx', [['Name', 'Age'], ['Alice', 30]])
29
+ #
30
+ # # 2. 一次性写入多个 Sheet
31
+ # XlsxKit::Writer.write('out.xlsx', { 'Sheet1' => rows1, 'Sheet2' => rows2 })
32
+ #
33
+ # # 3. 构建器模式
34
+ # XlsxKit::Writer.build('out.xlsx') do |wb|
35
+ # wb.add_sheet('People') do |s|
36
+ # s << ['Name', 'Age']
37
+ # s << ['Alice', 30]
38
+ # end
39
+ # end
40
+ #
41
+
42
+ class Writer
43
+ # OOXML 命名空间
44
+ NS_SPREADSHEET = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
45
+ NS_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
46
+ NS_CONTENT = 'http://schemas.openxmlformats.org/package/2006/content-types'
47
+ NS_PKG_REL = 'http://schemas.openxmlformats.org/package/2006/relationships'
48
+
49
+ attr_reader :sheets
50
+
51
+ ##
52
+ # 创建写入器。传 path 并给块时,块结束后自动 save。
53
+ def initialize(path = nil, &block)
54
+ @path = path
55
+ @sheets = []
56
+ if block
57
+ yield self
58
+ save(path) if path
59
+ end
60
+ end
61
+
62
+ ##
63
+ # 构建器模式:块内 add_sheet,块结束后写入文件。
64
+ def self.build(path = nil, &block)
65
+ new(path, &block)
66
+ end
67
+
68
+ ##
69
+ # 一次性写入:
70
+ # - data 为二维数组时,写入单个 Sheet(默认名 "Sheet1")
71
+ # - data 为 { name => rows } 时,写入多个 Sheet
72
+ # - rows 可为「数组的数组」或「Hash 的数组」(首行 Hash 的 keys 作为表头)
73
+ def self.write(path, data, sheet_name: 'Sheet1')
74
+ writer = new
75
+ if data.is_a?(Hash)
76
+ data.each { |name, rows| writer.add_sheet(name, rows) }
77
+ else
78
+ writer.add_sheet(sheet_name, data)
79
+ end
80
+ writer.save(path)
81
+ writer
82
+ end
83
+
84
+ ##
85
+ # 添加一个 Sheet。可传入初始 rows,也可用块逐行 <<。
86
+ def add_sheet(name = nil, rows = nil)
87
+ sheet = Sheet.new(name || "Sheet#{@sheets.length + 1}")
88
+ @sheets << sheet
89
+ sheet.add_rows(rows) if rows
90
+ yield sheet if block_given?
91
+ sheet
92
+ end
93
+
94
+ ##
95
+ # 写入文件。target 可为文件路径或可写 IO(如 StringIO)。
96
+ def save(target = @path)
97
+ raise ArgumentError, 'output path required' unless target
98
+
99
+ if target.respond_to?(:write)
100
+ zip = ZipWriter.new(target)
101
+ write_zip(zip)
102
+ zip.close
103
+ else
104
+ ZipWriter.open(target) { |zip| write_zip(zip) }
105
+ end
106
+ target
107
+ end
108
+
109
+ private
110
+
111
+ #---------------------------------------------------------------------------
112
+ # ZIP 包内容组装
113
+ #---------------------------------------------------------------------------
114
+
115
+ def write_zip(zip)
116
+ zip.add_entry('[Content_Types].xml', content_types_xml)
117
+ zip.add_entry('_rels/.rels', root_rels_xml)
118
+ zip.add_entry('xl/workbook.xml', workbook_xml)
119
+ zip.add_entry('xl/_rels/workbook.xml.rels', workbook_rels_xml)
120
+ @sheets.each_with_index do |sheet, i|
121
+ zip.add_entry("xl/worksheets/sheet#{i + 1}.xml", sheet_xml(sheet))
122
+ end
123
+ end
124
+
125
+ def content_types_xml
126
+ overrides = @sheets.each_with_index.map do |_, i|
127
+ %(<Override PartName="/xl/worksheets/sheet#{i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>)
128
+ end.join
129
+
130
+ %(<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n) +
131
+ %(<Types xmlns="#{NS_CONTENT}">) +
132
+ %(<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>) +
133
+ %(<Default Extension="xml" ContentType="application/xml"/>) +
134
+ %(<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>) +
135
+ overrides +
136
+ %(</Types>)
137
+ end
138
+
139
+ def root_rels_xml
140
+ %(<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n) +
141
+ %(<Relationships xmlns="#{NS_PKG_REL}">) +
142
+ %(<Relationship Id="rId1" Type="#{NS_REL}/officeDocument" Target="xl/workbook.xml"/>) +
143
+ %(</Relationships>)
144
+ end
145
+
146
+ def workbook_xml
147
+ sheets = @sheets.each_with_index.map do |sheet, i|
148
+ %(<sheet name="#{escape_xml(sheet.name)}" sheetId="#{i + 1}" r:id="rId#{i + 1}"/>)
149
+ end.join
150
+
151
+ %(<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n) +
152
+ %(<workbook xmlns="#{NS_SPREADSHEET}" xmlns:r="#{NS_REL}"><sheets>) +
153
+ sheets +
154
+ %(</sheets></workbook>)
155
+ end
156
+
157
+ def workbook_rels_xml
158
+ rels = @sheets.each_with_index.map do |_, i|
159
+ %(<Relationship Id="rId#{i + 1}" Type="#{NS_REL}/worksheet" Target="worksheets/sheet#{i + 1}.xml"/>)
160
+ end.join
161
+
162
+ %(<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n) +
163
+ %(<Relationships xmlns="#{NS_PKG_REL}">) +
164
+ rels +
165
+ %(</Relationships>)
166
+ end
167
+
168
+ def sheet_xml(sheet)
169
+ rows = +''
170
+ sheet.each_row do |values, row_num|
171
+ rows << build_row(values, row_num)
172
+ end
173
+
174
+ %(<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n) +
175
+ %(<worksheet xmlns="#{NS_SPREADSHEET}"><sheetData>) +
176
+ rows +
177
+ %(</sheetData></worksheet>)
178
+ end
179
+
180
+ #---------------------------------------------------------------------------
181
+ # 行 / 单元格生成
182
+ #---------------------------------------------------------------------------
183
+
184
+ def build_row(values, row_num)
185
+ cells = +''
186
+ values.each_with_index do |value, col_idx|
187
+ next if value.nil?
188
+ ref = "#{column_name(col_idx)}#{row_num}"
189
+ cells << build_cell(ref, value)
190
+ end
191
+ %(<row r="#{row_num}">#{cells}</row>)
192
+ end
193
+
194
+ def build_cell(ref, value)
195
+ case value
196
+ when true, false
197
+ %(<c r="#{ref}" t="b"><v>#{value ? 1 : 0}</v></c>)
198
+ when Numeric
199
+ %(<c r="#{ref}"><v>#{value}</v></c>)
200
+ else
201
+ %(<c r="#{ref}" t="inlineStr"><is><t>#{escape_xml(value.to_s)}</t></is></c>)
202
+ end
203
+ end
204
+
205
+ # 列序号 → 列名(0 → "A",25 → "Z",26 → "AA")
206
+ def column_name(idx)
207
+ name = +''
208
+ n = idx
209
+ loop do
210
+ name.prepend((65 + n % 26).chr)
211
+ n = n / 26 - 1
212
+ break if n < 0
213
+ end
214
+ name
215
+ end
216
+
217
+ def escape_xml(str)
218
+ str.to_s
219
+ .gsub('&', '&amp;')
220
+ .gsub('<', '&lt;')
221
+ .gsub('>', '&gt;')
222
+ .gsub('"', '&quot;')
223
+ .gsub("'", '&apos;')
224
+ end
225
+
226
+ #---------------------------------------------------------------------------
227
+ # Sheet 数据结构
228
+ #---------------------------------------------------------------------------
229
+
230
+ class Sheet
231
+ attr_reader :name
232
+
233
+ def initialize(name)
234
+ @name = name
235
+ @rows = []
236
+ end
237
+
238
+ def <<(row)
239
+ add_row(row)
240
+ end
241
+
242
+ def add_row(row)
243
+ @rows << row
244
+ self
245
+ end
246
+
247
+ def add_rows(rows)
248
+ rows.each { |r| add_row(r) }
249
+ self
250
+ end
251
+
252
+ ##
253
+ # 逐行产出 [values, row_num],统一将 Hash 行按表头对齐为数组。
254
+ # 若首行为 Hash,则以 keys 生成表头行,其余 Hash 行按表头对齐(缺失 key 补 nil)。
255
+ def each_row
256
+ return if @rows.empty?
257
+
258
+ if @rows.first.is_a?(Hash)
259
+ headers = @rows.first.keys
260
+ yield headers, 1
261
+ @rows.each_with_index do |row, idx|
262
+ values = row.is_a?(Hash) ? headers.map { |k| row[k] } : Array(row)
263
+ yield values, idx + 2
264
+ end
265
+ else
266
+ @rows.each_with_index do |row, idx|
267
+ yield Array(row), idx + 1
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
273
+
274
+ ##
275
+ # 便捷入口:XlsxKit.write(path, data, sheet_name: 'Sheet1')
276
+ def self.write(path, data, **opts)
277
+ Writer.write(path, data, **opts)
278
+ end
279
+ 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.2.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frampt
@@ -20,7 +20,9 @@ files:
20
20
  - lib/mdutils/rediscount.rb
21
21
  - lib/xlsxkit/sax_parser.rb
22
22
  - lib/xlsxkit/workbook.rb
23
+ - lib/xlsxkit/writer.rb
23
24
  - lib/xlsxkit/zip_reader.rb
25
+ - lib/xlsxkit/zip_writer.rb
24
26
  - lib/xmlutils/formatters.rb
25
27
  - lib/xmlutils/node.rb
26
28
  - lib/xmlutils/tokenizer.rb