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.
- checksums.yaml +4 -4
- data/README.md +700 -492
- data/lib/manticore.rb +5 -0
- data/lib/xlsxkit/sax_parser.rb +294 -0
- data/lib/xlsxkit/workbook.rb +427 -0
- data/lib/xlsxkit/writer.rb +279 -0
- data/lib/xlsxkit/zip_reader.rb +177 -0
- data/lib/xlsxkit/zip_writer.rb +118 -0
- metadata +7 -2
|
@@ -0,0 +1,427 @@
|
|
|
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 'json'
|
|
19
|
+
require 'stringio'
|
|
20
|
+
require_relative 'zip_reader'
|
|
21
|
+
require_relative 'sax_parser'
|
|
22
|
+
|
|
23
|
+
module XlsxKit
|
|
24
|
+
##
|
|
25
|
+
# XLSX Workbook 高层 API
|
|
26
|
+
#
|
|
27
|
+
# 用法:
|
|
28
|
+
# wb = XlsxKit::Workbook.open('data.xlsx')
|
|
29
|
+
# wb.sheet_names # => ["Sheet1", "Sheet2"]
|
|
30
|
+
# wb.read_rows('Sheet1') do |row|
|
|
31
|
+
# puts row.inspect # ["A1值", "B1值", ...]
|
|
32
|
+
# end
|
|
33
|
+
# wb.to_json('Sheet1') # => JSON 字符串
|
|
34
|
+
# wb.to_a('Sheet1') # => [[...], [...], ...] 原生 Ruby 数组
|
|
35
|
+
#
|
|
36
|
+
|
|
37
|
+
class Workbook
|
|
38
|
+
attr_reader :path
|
|
39
|
+
|
|
40
|
+
def initialize(path)
|
|
41
|
+
@path = path
|
|
42
|
+
@shared_strings = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.open(path)
|
|
46
|
+
wb = new(path)
|
|
47
|
+
yield wb if block_given?
|
|
48
|
+
wb
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# 返回所有 sheet 名称。
|
|
53
|
+
def sheet_names
|
|
54
|
+
@sheet_names ||= parse_workbook
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# 流式读取指定 sheet,逐行 yield。
|
|
59
|
+
#
|
|
60
|
+
# @param name [String, Integer] sheet 名称或序号(0-based)
|
|
61
|
+
# @param headers [Boolean] 第一行作为表头(返回 Hash 行)
|
|
62
|
+
# @yield [Array<value> 或 Hash] 数据行
|
|
63
|
+
def read_rows(name = 0, headers: false)
|
|
64
|
+
target = resolve_sheet(name)
|
|
65
|
+
return enum_for(:read_rows, name, headers: headers) unless block_given?
|
|
66
|
+
|
|
67
|
+
header_row = nil
|
|
68
|
+
row_handler = SheetRowHandler.new(load_shared_strings)
|
|
69
|
+
|
|
70
|
+
# 第一次扫描:解析 xml,逐行回调
|
|
71
|
+
ZipReader.open(@path) do |zip|
|
|
72
|
+
xml = zip.read_entry_utf8(target[:path])
|
|
73
|
+
return unless xml
|
|
74
|
+
|
|
75
|
+
io = StringIO.new(xml)
|
|
76
|
+
SAXParser.parse(io, row_handler)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
rows = row_handler.rows
|
|
80
|
+
if headers && !rows.empty?
|
|
81
|
+
header_row = rows.first
|
|
82
|
+
rows[1..].each do |row|
|
|
83
|
+
yield hashify(header_row, row)
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
rows.each { |row| yield row }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# 读取全部数据到原生 Ruby 二维数组。
|
|
92
|
+
def to_a(name = 0, headers: false)
|
|
93
|
+
rows = read_rows(name).to_a
|
|
94
|
+
return rows unless headers && rows.size > 1
|
|
95
|
+
|
|
96
|
+
header_row = rows.first
|
|
97
|
+
rows[1..].map { |row| hashify(header_row, row) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
##
|
|
101
|
+
# 读取并转为 JSON 字符串。
|
|
102
|
+
def to_json(name = 0, headers: false, pretty: false)
|
|
103
|
+
data = to_a(name, headers: headers)
|
|
104
|
+
pretty ? JSON.pretty_generate(data) : JSON.generate(data)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# 读取并写入 JSON 文件。
|
|
109
|
+
def to_json_file(path, name = 0, headers: false, pretty: false)
|
|
110
|
+
json = to_json(name, headers: headers, pretty: pretty)
|
|
111
|
+
File.write(path, json)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
#---------------------------------------------------------------------------
|
|
115
|
+
# CSV 输出
|
|
116
|
+
#---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
##
|
|
119
|
+
# 读取并转为 CSV 字符串。
|
|
120
|
+
def to_csv(name = 0, headers: false)
|
|
121
|
+
out = +''
|
|
122
|
+
first = true
|
|
123
|
+
read_rows(name) do |row|
|
|
124
|
+
if headers && first
|
|
125
|
+
first = false
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
out << row.map { |c| csv_escape(c) }.join(',') << "\n"
|
|
129
|
+
end
|
|
130
|
+
out
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
##
|
|
134
|
+
# 逐行生成 CSV(流式,适合大数据量直接写文件)。
|
|
135
|
+
def to_csv_file(path, name = 0, headers: false)
|
|
136
|
+
File.open(path, 'w:UTF-8') do |f|
|
|
137
|
+
first = true
|
|
138
|
+
read_rows(name) do |row|
|
|
139
|
+
if headers && first
|
|
140
|
+
first = false
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
f.puts(row.map { |c| csv_escape(c) }.join(','))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
#---------------------------------------------------------------------------
|
|
151
|
+
# Workbook XML 解析:提取 sheet 名称与文件路径
|
|
152
|
+
#---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def parse_workbook
|
|
155
|
+
names = []
|
|
156
|
+
ZipReader.open(@path) do |zip|
|
|
157
|
+
wb_xml = zip.read_entry_utf8('xl/workbook.xml')
|
|
158
|
+
return names unless wb_xml
|
|
159
|
+
|
|
160
|
+
handler = WorkbookHandler.new
|
|
161
|
+
SAXParser.parse(StringIO.new(wb_xml), handler)
|
|
162
|
+
names = handler.sheets
|
|
163
|
+
|
|
164
|
+
# 解析 rels 映射 rId → target
|
|
165
|
+
rels_xml = zip.read_entry_utf8('xl/_rels/workbook.xml.rels')
|
|
166
|
+
if rels_xml
|
|
167
|
+
rels_handler = RelsHandler.new
|
|
168
|
+
SAXParser.parse(StringIO.new(rels_xml), rels_handler)
|
|
169
|
+
@rels = rels_handler.rels
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 将 rId 映射为实际路径
|
|
174
|
+
@sheet_info = names.map do |name, rid|
|
|
175
|
+
target = @rels[rid]
|
|
176
|
+
path = target ? "xl/#{target}" : "xl/worksheets/sheet#{names.index([name, rid]) + 1}.xml"
|
|
177
|
+
{ name: name, path: path }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
@sheet_info.map { |s| s[:name] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resolve_sheet(name)
|
|
184
|
+
sheet_names if @sheet_info.nil?
|
|
185
|
+
case name
|
|
186
|
+
when Integer
|
|
187
|
+
@sheet_info[name]
|
|
188
|
+
when String
|
|
189
|
+
@sheet_info.find { |s| s[:name] == name }
|
|
190
|
+
else
|
|
191
|
+
raise ArgumentError, "sheet name must be String or Integer"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
#---------------------------------------------------------------------------
|
|
196
|
+
# Shared Strings 解析
|
|
197
|
+
#---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def load_shared_strings
|
|
200
|
+
return @shared_strings if @shared_strings
|
|
201
|
+
|
|
202
|
+
@shared_strings = []
|
|
203
|
+
ZipReader.open(@path) do |zip|
|
|
204
|
+
xml = zip.read_entry_utf8('xl/sharedStrings.xml')
|
|
205
|
+
return @shared_strings unless xml
|
|
206
|
+
|
|
207
|
+
handler = SharedStringsHandler.new
|
|
208
|
+
SAXParser.parse(StringIO.new(xml), handler)
|
|
209
|
+
@shared_strings = handler.strings
|
|
210
|
+
end
|
|
211
|
+
@shared_strings
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
#---------------------------------------------------------------------------
|
|
215
|
+
# 辅助方法
|
|
216
|
+
#---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def hashify(header_row, row)
|
|
219
|
+
hash = {}
|
|
220
|
+
header_row.each_with_index do |key, i|
|
|
221
|
+
hash[key] = row[i]
|
|
222
|
+
end
|
|
223
|
+
hash
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def csv_escape(val)
|
|
227
|
+
return '' if val.nil?
|
|
228
|
+
s = val.to_s
|
|
229
|
+
if s.include?(',') || s.include?('"') || s.include?("\n")
|
|
230
|
+
'"' + s.gsub('"', '""') + '"'
|
|
231
|
+
else
|
|
232
|
+
s
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
#---------------------------------------------------------------------------
|
|
237
|
+
# Workbook XML SAX Handler
|
|
238
|
+
#---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
class WorkbookHandler
|
|
241
|
+
attr_reader :sheets
|
|
242
|
+
|
|
243
|
+
def initialize
|
|
244
|
+
@sheets = []
|
|
245
|
+
@in_sheet = false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def start_element(name, attrs)
|
|
249
|
+
if name == 'sheet'
|
|
250
|
+
@sheets << [attrs['name'], attrs['r:id'] || attrs['id']]
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def end_element(name); end
|
|
255
|
+
def characters(text); end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
#---------------------------------------------------------------------------
|
|
259
|
+
# Rels XML SAX Handler
|
|
260
|
+
#---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
class RelsHandler
|
|
263
|
+
attr_reader :rels
|
|
264
|
+
|
|
265
|
+
def initialize
|
|
266
|
+
@rels = {}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def start_element(name, attrs)
|
|
270
|
+
if name == 'Relationship'
|
|
271
|
+
@rels[attrs['Id']] = attrs['Target']
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def end_element(name); end
|
|
276
|
+
def characters(text); end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
#---------------------------------------------------------------------------
|
|
280
|
+
# Shared Strings SAX Handler
|
|
281
|
+
#---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
class SharedStringsHandler
|
|
284
|
+
attr_reader :strings
|
|
285
|
+
|
|
286
|
+
def initialize
|
|
287
|
+
@strings = []
|
|
288
|
+
@in_si = false
|
|
289
|
+
@in_t = false
|
|
290
|
+
@chars = nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def start_element(name, attrs)
|
|
294
|
+
case name
|
|
295
|
+
when 'si' then @in_si = true; @chars = +''
|
|
296
|
+
when 't' then @in_t = true
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def characters(text)
|
|
301
|
+
@chars << text if @in_si && @in_t
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def end_element(name)
|
|
305
|
+
case name
|
|
306
|
+
when 'si'
|
|
307
|
+
@strings << (@chars || '')
|
|
308
|
+
@in_si = false
|
|
309
|
+
@chars = nil
|
|
310
|
+
when 't'
|
|
311
|
+
@in_t = false
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
#---------------------------------------------------------------------------
|
|
317
|
+
# Sheet Row SAX Handler
|
|
318
|
+
#
|
|
319
|
+
# 解析 <sheetData> 下的 <row> → <c> → <v>/<is><t> 结构,
|
|
320
|
+
# 将每行的单元格值提取为数组,逐行收集。
|
|
321
|
+
#---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
class SheetRowHandler
|
|
324
|
+
attr_reader :rows
|
|
325
|
+
|
|
326
|
+
def initialize(shared_strings)
|
|
327
|
+
@sst = shared_strings
|
|
328
|
+
@rows = []
|
|
329
|
+
@in_row = false
|
|
330
|
+
@in_cell = false
|
|
331
|
+
@in_v = false
|
|
332
|
+
@in_is = false
|
|
333
|
+
@in_is_t = false
|
|
334
|
+
@cell_type = nil
|
|
335
|
+
@cell_ref = nil
|
|
336
|
+
@chars = nil
|
|
337
|
+
@current_row = nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def start_element(name, attrs)
|
|
341
|
+
case name
|
|
342
|
+
when 'row'
|
|
343
|
+
@in_row = true
|
|
344
|
+
@current_row = []
|
|
345
|
+
when 'c'
|
|
346
|
+
@in_cell = true
|
|
347
|
+
@cell_type = attrs['t']
|
|
348
|
+
@cell_ref = attrs['r']
|
|
349
|
+
@chars = +''
|
|
350
|
+
when 'v'
|
|
351
|
+
@in_v = true
|
|
352
|
+
when 'is'
|
|
353
|
+
@in_is = true
|
|
354
|
+
when 't'
|
|
355
|
+
@in_is_t = true if @in_is
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def characters(text)
|
|
360
|
+
@chars << text if @in_cell && (@in_v || (@in_is && @in_is_t))
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def end_element(name)
|
|
364
|
+
case name
|
|
365
|
+
when 'row'
|
|
366
|
+
@rows << @current_row
|
|
367
|
+
@in_row = false
|
|
368
|
+
@current_row = nil
|
|
369
|
+
when 'c'
|
|
370
|
+
val = resolve_cell_value
|
|
371
|
+
if @cell_ref
|
|
372
|
+
col = col_ref_to_index(@cell_ref)
|
|
373
|
+
@current_row.fill(nil, @current_row.length...col) if col > @current_row.length
|
|
374
|
+
@current_row[col] = val
|
|
375
|
+
else
|
|
376
|
+
@current_row << val
|
|
377
|
+
end
|
|
378
|
+
@in_cell = false
|
|
379
|
+
@cell_type = nil
|
|
380
|
+
@cell_ref = nil
|
|
381
|
+
@chars = nil
|
|
382
|
+
when 'v'
|
|
383
|
+
@in_v = false
|
|
384
|
+
when 'is'
|
|
385
|
+
@in_is = false
|
|
386
|
+
when 't'
|
|
387
|
+
@in_is_t = false if @in_is
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
private
|
|
392
|
+
|
|
393
|
+
def resolve_cell_value
|
|
394
|
+
raw = @chars ? @chars.strip : ''
|
|
395
|
+
return nil if raw.empty?
|
|
396
|
+
|
|
397
|
+
case @cell_type
|
|
398
|
+
when 's' # shared string
|
|
399
|
+
idx = raw.to_i
|
|
400
|
+
@sst[idx] || nil
|
|
401
|
+
when 'str' # formula string
|
|
402
|
+
raw
|
|
403
|
+
when 'inlineStr' # inline string
|
|
404
|
+
raw
|
|
405
|
+
when 'b' # boolean
|
|
406
|
+
raw == '1'
|
|
407
|
+
when 'e' # error
|
|
408
|
+
raw
|
|
409
|
+
else # numeric
|
|
410
|
+
num?(raw) ? raw.to_f : raw
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# "A1" → 0, "B3" → 1, "AA1" → 26
|
|
415
|
+
def col_ref_to_index(ref)
|
|
416
|
+
letters = ref[/^[A-Z]+/]
|
|
417
|
+
col = 0
|
|
418
|
+
letters.each_byte { |b| col = col * 26 + (b - 64) }
|
|
419
|
+
col - 1
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def num?(s)
|
|
423
|
+
s =~ /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
@@ -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
|