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,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('&', '&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