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.
- checksums.yaml +4 -4
- data/README.md +700 -668
- data/lib/manticore.rb +2 -0
- data/lib/xlsxkit/writer.rb +279 -0
- data/lib/xlsxkit/zip_writer.rb +118 -0
- metadata +3 -1
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('&', '&')
|
|
220
|
+
.gsub('<', '<')
|
|
221
|
+
.gsub('>', '>')
|
|
222
|
+
.gsub('"', '"')
|
|
223
|
+
.gsub("'", ''')
|
|
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.
|
|
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
|