xlsxrb 0.1.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 +7 -0
- data/.devcontainer/Dockerfile +64 -0
- data/.devcontainer/devcontainer.json +17 -0
- data/CHANGELOG.md +12 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +290 -0
- data/benchmark.rb +390 -0
- data/docs/ARCHITECTURE.md +488 -0
- data/docs/SPEC_SOURCES.md +42 -0
- data/docs/visual/VisualGallery.md +4684 -0
- data/docs/wasm/wasm_doc_helper.css +189 -0
- data/docs/wasm/wasm_doc_helper.js +320 -0
- data/lib/xlsxrb/elements/cell.rb +77 -0
- data/lib/xlsxrb/elements/column.rb +28 -0
- data/lib/xlsxrb/elements/row.rb +47 -0
- data/lib/xlsxrb/elements/types.rb +36 -0
- data/lib/xlsxrb/elements/workbook.rb +47 -0
- data/lib/xlsxrb/elements/worksheet.rb +50 -0
- data/lib/xlsxrb/elements.rb +15 -0
- data/lib/xlsxrb/ooxml/reader.rb +8048 -0
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
- data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
- data/lib/xlsxrb/ooxml/utils.rb +122 -0
- data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
- data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
- data/lib/xlsxrb/ooxml/writer.rb +5450 -0
- data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
- data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
- data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
- data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
- data/lib/xlsxrb/ooxml.rb +22 -0
- data/lib/xlsxrb/style_builder.rb +241 -0
- data/lib/xlsxrb/version.rb +5 -0
- data/lib/xlsxrb.rb +1427 -0
- data/measure_memory.rb +42 -0
- data/sig/xlsxrb.rbs +23 -0
- data/vendor/sdk_runner/Program.cs +91 -0
- data/vendor/sdk_runner/sdk_runner.csproj +13 -0
- metadata +144 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "xml_builder"
|
|
5
|
+
|
|
6
|
+
module Xlsxrb
|
|
7
|
+
module Ooxml
|
|
8
|
+
# Generates worksheet XML for a list of rows.
|
|
9
|
+
# Supports streaming: rows can be written one at a time.
|
|
10
|
+
class WorksheetWriter
|
|
11
|
+
SSML_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
12
|
+
DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
13
|
+
|
|
14
|
+
def initialize(io)
|
|
15
|
+
@io = io
|
|
16
|
+
@builder = XmlBuilder.new(@io)
|
|
17
|
+
@started = false
|
|
18
|
+
@finished = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Write the worksheet header. Call once before writing rows.
|
|
22
|
+
# Options for pre-sheetData elements:
|
|
23
|
+
# sheet_properties: Hash of sheet-level properties (:tab_color, etc.)
|
|
24
|
+
# freeze_pane: { row:, col:, state: :frozen }
|
|
25
|
+
# split_pane: { x_split:, y_split:, top_left_cell: }
|
|
26
|
+
# selection: { active_cell:, sqref:, pane: }
|
|
27
|
+
# sheet_view: Hash of sheet view properties
|
|
28
|
+
def start(columns: [], sheet_properties: nil, freeze_pane: nil, split_pane: nil, selection: nil, sheet_view: nil)
|
|
29
|
+
return if @started
|
|
30
|
+
|
|
31
|
+
@started = true
|
|
32
|
+
@builder.declaration
|
|
33
|
+
@builder.open_tag("worksheet", { xmlns: SSML_NS, "xmlns:r": DOC_REL_NS })
|
|
34
|
+
|
|
35
|
+
write_sheet_properties(sheet_properties) if sheet_properties && !sheet_properties.empty?
|
|
36
|
+
write_sheet_views(freeze_pane: freeze_pane, split_pane: split_pane, selection: selection, sheet_view: sheet_view) if freeze_pane || split_pane || selection || (sheet_view && !sheet_view.empty?)
|
|
37
|
+
write_columns(columns) unless columns.empty?
|
|
38
|
+
|
|
39
|
+
@builder.open_tag("sheetData")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Write a single row. Automatically calls start if needed.
|
|
43
|
+
def write_row(row_index, cells, attrs: {}, unmapped: [], sst_index: nil)
|
|
44
|
+
start unless @started
|
|
45
|
+
|
|
46
|
+
row_num = row_index + 1
|
|
47
|
+
row_num_str = row_num.to_s
|
|
48
|
+
io = @io
|
|
49
|
+
io.write("<row r=\"")
|
|
50
|
+
io.write(row_num_str)
|
|
51
|
+
io.write('"')
|
|
52
|
+
if attrs[:height]
|
|
53
|
+
io.write(' ht="')
|
|
54
|
+
io.write(attrs[:height].to_s)
|
|
55
|
+
io.write('" customHeight="1"')
|
|
56
|
+
elsif attrs[:custom_height]
|
|
57
|
+
io.write(' customHeight="1"')
|
|
58
|
+
end
|
|
59
|
+
io.write(' hidden="1"') if attrs[:hidden]
|
|
60
|
+
if attrs[:outline_level]
|
|
61
|
+
io.write(' outlineLevel="')
|
|
62
|
+
io.write(attrs[:outline_level].to_s)
|
|
63
|
+
io.write('"')
|
|
64
|
+
end
|
|
65
|
+
io.write(">")
|
|
66
|
+
|
|
67
|
+
cells.each do |cell|
|
|
68
|
+
if cell.is_a?(Hash)
|
|
69
|
+
value = cell[:value]
|
|
70
|
+
style_id = cell[:style_index]
|
|
71
|
+
col_ref = cell[:ref] || "#{column_letter(cell[:column_index])}#{row_num_str}"
|
|
72
|
+
formula = cell[:formula]
|
|
73
|
+
formula_ca = cell[:formula_ca]
|
|
74
|
+
cell_type_val = cell[:type]
|
|
75
|
+
else
|
|
76
|
+
value = cell.value
|
|
77
|
+
style_id = cell.style_index
|
|
78
|
+
col_ref = cell.ref || "#{column_letter(cell.column_index)}#{row_num_str}"
|
|
79
|
+
formula = cell.formula
|
|
80
|
+
formula_ca = false
|
|
81
|
+
cell_type_val = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if value.nil? && formula.nil?
|
|
85
|
+
io.write('<c r="')
|
|
86
|
+
io.write(col_ref)
|
|
87
|
+
io.write('"')
|
|
88
|
+
if style_id
|
|
89
|
+
io.write(' s="')
|
|
90
|
+
io.write(style_id.to_s)
|
|
91
|
+
io.write('"')
|
|
92
|
+
end
|
|
93
|
+
io.write("/>")
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if value.is_a?(String) && value.start_with?("=") && !formula
|
|
98
|
+
formula = value
|
|
99
|
+
value = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
xml_val = value
|
|
103
|
+
type = cell_type_val
|
|
104
|
+
formula_expr = nil
|
|
105
|
+
|
|
106
|
+
if formula
|
|
107
|
+
if formula.is_a?(Xlsxrb::Elements::Formula)
|
|
108
|
+
formula_expr = formula.expression
|
|
109
|
+
formula_ca = formula.calculate_always
|
|
110
|
+
xml_val = formula.cached_value || nil
|
|
111
|
+
else
|
|
112
|
+
formula_expr = formula
|
|
113
|
+
end
|
|
114
|
+
formula_expr = formula_expr[1..] if formula_expr.start_with?("=")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if !formula_expr && !type
|
|
118
|
+
case value
|
|
119
|
+
when String
|
|
120
|
+
if sst_index && (idx = sst_index[value])
|
|
121
|
+
xml_val = idx
|
|
122
|
+
type = "s"
|
|
123
|
+
else
|
|
124
|
+
type = "inlineStr"
|
|
125
|
+
end
|
|
126
|
+
when Xlsxrb::Elements::RichText
|
|
127
|
+
if sst_index && (idx = sst_index[value])
|
|
128
|
+
xml_val = idx
|
|
129
|
+
type = "s"
|
|
130
|
+
else
|
|
131
|
+
type = "inlineStr"
|
|
132
|
+
xml_val = value
|
|
133
|
+
end
|
|
134
|
+
when true
|
|
135
|
+
xml_val = "1"
|
|
136
|
+
type = "b"
|
|
137
|
+
when false
|
|
138
|
+
xml_val = "0"
|
|
139
|
+
type = "b"
|
|
140
|
+
when Date
|
|
141
|
+
xml_val = Xlsxrb::Ooxml::Utils.date_to_serial(value)
|
|
142
|
+
when Time
|
|
143
|
+
xml_val = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
io.write('<c r="')
|
|
148
|
+
io.write(col_ref)
|
|
149
|
+
io.write('"')
|
|
150
|
+
if style_id
|
|
151
|
+
io.write(' s="')
|
|
152
|
+
io.write(style_id.to_s)
|
|
153
|
+
io.write('"')
|
|
154
|
+
end
|
|
155
|
+
if type
|
|
156
|
+
io.write(' t="')
|
|
157
|
+
io.write(type)
|
|
158
|
+
io.write('"')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if type == "inlineStr"
|
|
162
|
+
if xml_val.is_a?(Xlsxrb::Elements::RichText)
|
|
163
|
+
io.write("><is>")
|
|
164
|
+
xml_val.runs.each do |run|
|
|
165
|
+
font = run[:font]
|
|
166
|
+
if font && !font.empty?
|
|
167
|
+
io.write("<r><rPr>")
|
|
168
|
+
io.write("<b/>") if font[:bold]
|
|
169
|
+
io.write("<i/>") if font[:italic]
|
|
170
|
+
io.write("<strike/>") if font[:strike]
|
|
171
|
+
if font[:underline]
|
|
172
|
+
if font[:underline] == true
|
|
173
|
+
io.write("<u/>")
|
|
174
|
+
else
|
|
175
|
+
io.write("<u val=\"#{font[:underline]}\"/>")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
io.write("<vertAlign val=\"#{font[:vert_align]}\"/>") if font[:vert_align]
|
|
179
|
+
io.write("<sz val=\"#{font[:sz]}\"/>") if font[:sz]
|
|
180
|
+
if font[:color]
|
|
181
|
+
io.write("<color rgb=\"#{font[:color]}\"/>")
|
|
182
|
+
elsif font[:theme]
|
|
183
|
+
tint_attr = font[:tint] ? " tint=\"#{font[:tint]}\"" : ""
|
|
184
|
+
io.write("<color theme=\"#{font[:theme]}\"#{tint_attr}/>")
|
|
185
|
+
end
|
|
186
|
+
io.write("<rFont val=\"#{escape_xml(font[:name])}\"/>") if font[:name]
|
|
187
|
+
io.write("<family val=\"#{font[:family]}\"/>") if font[:family]
|
|
188
|
+
io.write("<scheme val=\"#{font[:scheme]}\"/>") if font[:scheme]
|
|
189
|
+
io.write("</rPr><t>")
|
|
190
|
+
else
|
|
191
|
+
io.write("<r><t>")
|
|
192
|
+
end
|
|
193
|
+
io.write(escape_xml(run[:text]))
|
|
194
|
+
io.write("</t></r>")
|
|
195
|
+
end
|
|
196
|
+
io.write("</is></c>")
|
|
197
|
+
else
|
|
198
|
+
io.write("><is><t>")
|
|
199
|
+
io.write(escape_xml(xml_val.to_s))
|
|
200
|
+
io.write("</t></is></c>")
|
|
201
|
+
end
|
|
202
|
+
elsif formula_expr
|
|
203
|
+
if formula_ca
|
|
204
|
+
io.write('><f ca="1">')
|
|
205
|
+
else
|
|
206
|
+
io.write("><f>")
|
|
207
|
+
end
|
|
208
|
+
io.write(escape_xml(formula_expr))
|
|
209
|
+
io.write("</f>")
|
|
210
|
+
if xml_val
|
|
211
|
+
io.write("<v>")
|
|
212
|
+
io.write(xml_val.to_s)
|
|
213
|
+
io.write("</v></c>")
|
|
214
|
+
else
|
|
215
|
+
io.write("</c>")
|
|
216
|
+
end
|
|
217
|
+
else
|
|
218
|
+
io.write("><v>")
|
|
219
|
+
io.write(xml_val.to_s)
|
|
220
|
+
io.write("</v></c>")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
unmapped.each { |node| @builder.write_unmapped(node) }
|
|
225
|
+
|
|
226
|
+
io.write("</row>")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Highly optimized row writing for StreamWriter that avoids allocating intermediate Hashes.
|
|
230
|
+
def write_row_values(row_index, values, styles: nil, style_map: nil, sst: nil, sst_index: nil, attrs: nil)
|
|
231
|
+
start unless @started
|
|
232
|
+
|
|
233
|
+
row_num = row_index + 1
|
|
234
|
+
row_num_str = row_num.to_s
|
|
235
|
+
style_lookup_enabled = styles && style_map && (styles.is_a?(Array) || styles.is_a?(Hash))
|
|
236
|
+
io = @io
|
|
237
|
+
io.write("<row r=\"")
|
|
238
|
+
io.write(row_num_str)
|
|
239
|
+
io.write('"')
|
|
240
|
+
if attrs
|
|
241
|
+
if attrs[:height]
|
|
242
|
+
io.write(' ht="')
|
|
243
|
+
io.write(attrs[:height].to_s)
|
|
244
|
+
io.write('" customHeight="1"')
|
|
245
|
+
end
|
|
246
|
+
io.write(' hidden="1"') if attrs[:hidden]
|
|
247
|
+
if attrs[:outline_level]
|
|
248
|
+
io.write(' outlineLevel="')
|
|
249
|
+
io.write(attrs[:outline_level].to_s)
|
|
250
|
+
io.write('"')
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
io.write(">")
|
|
254
|
+
|
|
255
|
+
col_index = 0
|
|
256
|
+
while col_index < values.length
|
|
257
|
+
value = values[col_index]
|
|
258
|
+
style_id = nil
|
|
259
|
+
if style_lookup_enabled
|
|
260
|
+
style_name = styles[col_index]
|
|
261
|
+
style_id = style_map[style_name] if style_name
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
col_ref = column_letter(col_index)
|
|
265
|
+
|
|
266
|
+
if value.nil?
|
|
267
|
+
if style_id
|
|
268
|
+
io.write('<c r="')
|
|
269
|
+
io.write(col_ref)
|
|
270
|
+
io.write(row_num_str)
|
|
271
|
+
io.write('" s="')
|
|
272
|
+
io.write(style_id.to_s)
|
|
273
|
+
io.write('"/>')
|
|
274
|
+
end
|
|
275
|
+
col_index += 1
|
|
276
|
+
next
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Auto-convert string starting with '=' to formula
|
|
280
|
+
if value.is_a?(String) && value.start_with?("=")
|
|
281
|
+
formula_expr = value
|
|
282
|
+
xml_val = nil
|
|
283
|
+
else
|
|
284
|
+
formula_expr = nil
|
|
285
|
+
xml_val = value
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
type = nil
|
|
289
|
+
formula_ca = false
|
|
290
|
+
|
|
291
|
+
case value
|
|
292
|
+
when Xlsxrb::Elements::Formula
|
|
293
|
+
formula_expr = value.expression
|
|
294
|
+
formula_ca = value.calculate_always
|
|
295
|
+
xml_val = value.cached_value
|
|
296
|
+
case value.cached_value
|
|
297
|
+
when String
|
|
298
|
+
type = "str"
|
|
299
|
+
when true
|
|
300
|
+
type = "b"
|
|
301
|
+
xml_val = "1"
|
|
302
|
+
when false
|
|
303
|
+
type = "b"
|
|
304
|
+
xml_val = "0"
|
|
305
|
+
end
|
|
306
|
+
when String
|
|
307
|
+
if value.start_with?("=")
|
|
308
|
+
# already set formula_expr
|
|
309
|
+
else
|
|
310
|
+
idx = sst_index[value]
|
|
311
|
+
unless idx
|
|
312
|
+
sst << value
|
|
313
|
+
idx = sst.size - 1
|
|
314
|
+
sst_index[value] = idx
|
|
315
|
+
end
|
|
316
|
+
xml_val = idx
|
|
317
|
+
type = "s"
|
|
318
|
+
end
|
|
319
|
+
when Xlsxrb::Elements::RichText
|
|
320
|
+
idx = sst_index[value]
|
|
321
|
+
unless idx
|
|
322
|
+
sst << value
|
|
323
|
+
idx = sst.size - 1
|
|
324
|
+
sst_index[value] = idx
|
|
325
|
+
end
|
|
326
|
+
xml_val = idx
|
|
327
|
+
type = "s"
|
|
328
|
+
when true
|
|
329
|
+
xml_val = "1"
|
|
330
|
+
type = "b"
|
|
331
|
+
when false
|
|
332
|
+
xml_val = "0"
|
|
333
|
+
type = "b"
|
|
334
|
+
when Date
|
|
335
|
+
xml_val = Xlsxrb::Ooxml::Utils.date_to_serial(value)
|
|
336
|
+
when Time
|
|
337
|
+
xml_val = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
|
|
338
|
+
when Xlsxrb::Elements::CellError
|
|
339
|
+
xml_val = value.code
|
|
340
|
+
type = "e"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
formula_expr = formula_expr[1..] if formula_expr&.start_with?("=")
|
|
344
|
+
|
|
345
|
+
io.write('<c r="')
|
|
346
|
+
io.write(col_ref)
|
|
347
|
+
io.write(row_num_str)
|
|
348
|
+
io.write('"')
|
|
349
|
+
if style_id
|
|
350
|
+
io.write(' s="')
|
|
351
|
+
io.write(style_id.to_s)
|
|
352
|
+
io.write('"')
|
|
353
|
+
end
|
|
354
|
+
if type
|
|
355
|
+
io.write(' t="')
|
|
356
|
+
io.write(type)
|
|
357
|
+
io.write('"')
|
|
358
|
+
end
|
|
359
|
+
if formula_expr
|
|
360
|
+
if formula_ca
|
|
361
|
+
io.write('><f ca="1">')
|
|
362
|
+
else
|
|
363
|
+
io.write("><f>")
|
|
364
|
+
end
|
|
365
|
+
io.write(escape_xml(formula_expr))
|
|
366
|
+
io.write("</f>")
|
|
367
|
+
if xml_val
|
|
368
|
+
io.write("<v>")
|
|
369
|
+
io.write(xml_val.to_s)
|
|
370
|
+
io.write("</v></c>")
|
|
371
|
+
else
|
|
372
|
+
io.write("</c>")
|
|
373
|
+
end
|
|
374
|
+
else
|
|
375
|
+
io.write("><v>")
|
|
376
|
+
io.write(xml_val.to_s)
|
|
377
|
+
io.write("</v></c>")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
col_index += 1
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
io.write("</row>")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Write the worksheet footer. Call once after all rows.
|
|
387
|
+
# Options for post-sheetData elements (in OOXML order):
|
|
388
|
+
def finish(drawing_rid: nil, sheet_protection: nil, auto_filter: nil,
|
|
389
|
+
filter_columns: nil, sort_state: nil, merge_cells: nil,
|
|
390
|
+
conditional_formats: nil, data_validations: nil,
|
|
391
|
+
hyperlinks: nil, print_options: nil, page_margins: nil,
|
|
392
|
+
page_setup: nil, header_footer: nil, row_breaks: nil,
|
|
393
|
+
col_breaks: nil, tables: nil, table_start_rid: nil,
|
|
394
|
+
legacy_drawing_rid: nil, sparkline_groups: nil)
|
|
395
|
+
return if @finished
|
|
396
|
+
|
|
397
|
+
start unless @started
|
|
398
|
+
@finished = true
|
|
399
|
+
@builder.close_tag("sheetData")
|
|
400
|
+
|
|
401
|
+
# Elements must appear in OOXML specification order after sheetData
|
|
402
|
+
write_sheet_protection(sheet_protection) if sheet_protection
|
|
403
|
+
write_auto_filter(auto_filter, filter_columns, sort_state) if auto_filter
|
|
404
|
+
write_merge_cells(merge_cells) if merge_cells && !merge_cells.empty?
|
|
405
|
+
write_conditional_formatting(conditional_formats) if conditional_formats && !conditional_formats.empty?
|
|
406
|
+
write_data_validations(data_validations) if data_validations && !data_validations.empty?
|
|
407
|
+
write_hyperlinks(hyperlinks) if hyperlinks && !hyperlinks.empty?
|
|
408
|
+
write_print_options(print_options) if print_options && !print_options.empty?
|
|
409
|
+
write_page_margins(page_margins) if page_margins
|
|
410
|
+
write_page_setup(page_setup) if page_setup && !page_setup.empty?
|
|
411
|
+
write_header_footer(header_footer) if header_footer && !header_footer.empty?
|
|
412
|
+
write_row_breaks(row_breaks) if row_breaks && !row_breaks.empty?
|
|
413
|
+
write_col_breaks(col_breaks) if col_breaks && !col_breaks.empty?
|
|
414
|
+
@builder.empty_tag("drawing", { "r:id": drawing_rid }) if drawing_rid
|
|
415
|
+
@builder.empty_tag("legacyDrawing", { "r:id": legacy_drawing_rid }) if legacy_drawing_rid
|
|
416
|
+
write_table_parts(tables, table_start_rid) if tables && !tables.empty?
|
|
417
|
+
write_sparklines(sparkline_groups) if sparkline_groups && !sparkline_groups.empty?
|
|
418
|
+
@builder.close_tag("worksheet")
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
private
|
|
422
|
+
|
|
423
|
+
# --- Pre-sheetData elements ---
|
|
424
|
+
|
|
425
|
+
def write_sheet_properties(props)
|
|
426
|
+
attrs = {}
|
|
427
|
+
has_children = props[:tab_color] || !props[:fit_to_page].nil? || !props[:outline_below].nil? || !props[:outline_right].nil?
|
|
428
|
+
if has_children
|
|
429
|
+
@builder.open_tag("sheetPr", attrs)
|
|
430
|
+
@builder.empty_tag("tabColor", { rgb: props[:tab_color] }) if props[:tab_color]
|
|
431
|
+
|
|
432
|
+
outline_attrs = {}
|
|
433
|
+
outline_attrs[:summaryBelow] = props[:outline_below] ? "1" : "0" unless props[:outline_below].nil?
|
|
434
|
+
outline_attrs[:summaryRight] = props[:outline_right] ? "1" : "0" unless props[:outline_right].nil?
|
|
435
|
+
@builder.empty_tag("outlinePr", outline_attrs) unless outline_attrs.empty?
|
|
436
|
+
|
|
437
|
+
unless props[:fit_to_page].nil?
|
|
438
|
+
@builder.empty_tag("pageSetUpPr", { fitToPage: props[:fit_to_page] ? "1" : "0" })
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
@builder.close_tag("sheetPr")
|
|
442
|
+
else
|
|
443
|
+
@builder.empty_tag("sheetPr", attrs) unless attrs.empty?
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def write_sheet_views(freeze_pane: nil, split_pane: nil, selection: nil, sheet_view: nil)
|
|
448
|
+
@builder.open_tag("sheetViews")
|
|
449
|
+
sv_attrs = { tabSelected: "1", workbookViewId: "0" }
|
|
450
|
+
if sheet_view
|
|
451
|
+
sv_attrs[:showGridLines] = "0" if sheet_view[:show_grid_lines] == false
|
|
452
|
+
sv_attrs[:showRowColHeaders] = "0" if sheet_view[:show_row_col_headers] == false
|
|
453
|
+
sv_attrs[:rightToLeft] = "1" if sheet_view[:right_to_left]
|
|
454
|
+
sv_attrs[:zoomScale] = sheet_view[:zoom_scale].to_s if sheet_view[:zoom_scale]
|
|
455
|
+
end
|
|
456
|
+
@builder.open_tag("sheetView", sv_attrs)
|
|
457
|
+
if freeze_pane
|
|
458
|
+
pane_attrs = {}
|
|
459
|
+
pane_attrs[:xSplit] = freeze_pane[:col].to_s if freeze_pane[:col]&.positive?
|
|
460
|
+
pane_attrs[:ySplit] = freeze_pane[:row].to_s if freeze_pane[:row]&.positive?
|
|
461
|
+
top_left_col = column_letter(freeze_pane[:col] || 0)
|
|
462
|
+
top_left_row = (freeze_pane[:row] || 0) + 1
|
|
463
|
+
pane_attrs[:topLeftCell] = "#{top_left_col}#{top_left_row}"
|
|
464
|
+
pane_attrs[:state] = "frozen"
|
|
465
|
+
# Determine active pane
|
|
466
|
+
pane_attrs[:activePane] = if (freeze_pane[:col] || 0).positive? && (freeze_pane[:row] || 0).positive?
|
|
467
|
+
"bottomRight"
|
|
468
|
+
elsif (freeze_pane[:col] || 0).positive?
|
|
469
|
+
"topRight"
|
|
470
|
+
else
|
|
471
|
+
"bottomLeft"
|
|
472
|
+
end
|
|
473
|
+
@builder.empty_tag("pane", pane_attrs)
|
|
474
|
+
elsif split_pane
|
|
475
|
+
pane_attrs = {}
|
|
476
|
+
pane_attrs[:xSplit] = split_pane[:x_split].to_s if split_pane[:x_split]&.positive?
|
|
477
|
+
pane_attrs[:ySplit] = split_pane[:y_split].to_s if split_pane[:y_split]&.positive?
|
|
478
|
+
pane_attrs[:topLeftCell] = split_pane[:top_left_cell] if split_pane[:top_left_cell]
|
|
479
|
+
@builder.empty_tag("pane", pane_attrs)
|
|
480
|
+
end
|
|
481
|
+
if selection
|
|
482
|
+
sel_attrs = {}
|
|
483
|
+
sel_attrs[:activeCell] = selection[:active_cell] if selection[:active_cell]
|
|
484
|
+
sel_attrs[:sqref] = selection[:sqref] || selection[:active_cell] if selection[:active_cell]
|
|
485
|
+
sel_attrs[:pane] = selection[:pane] if selection[:pane]
|
|
486
|
+
@builder.empty_tag("selection", sel_attrs)
|
|
487
|
+
end
|
|
488
|
+
@builder.close_tag("sheetView")
|
|
489
|
+
@builder.close_tag("sheetViews")
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# --- Post-sheetData elements (in OOXML order) ---
|
|
493
|
+
|
|
494
|
+
def write_sheet_protection(opts)
|
|
495
|
+
attrs = {}
|
|
496
|
+
attrs[:sheet] = "1" if opts[:sheet] != false
|
|
497
|
+
attrs[:objects] = "1" if opts[:objects]
|
|
498
|
+
attrs[:scenarios] = "1" if opts[:scenarios]
|
|
499
|
+
attrs[:formatCells] = "0" if opts[:format_cells] == false
|
|
500
|
+
attrs[:formatColumns] = "0" if opts[:format_columns] == false
|
|
501
|
+
attrs[:formatRows] = "0" if opts[:format_rows] == false
|
|
502
|
+
attrs[:insertColumns] = "0" if opts[:insert_columns] == false
|
|
503
|
+
attrs[:insertRows] = "0" if opts[:insert_rows] == false
|
|
504
|
+
attrs[:insertHyperlinks] = "0" if opts[:insert_hyperlinks] == false
|
|
505
|
+
attrs[:deleteColumns] = "0" if opts[:delete_columns] == false
|
|
506
|
+
attrs[:deleteRows] = "0" if opts[:delete_rows] == false
|
|
507
|
+
attrs[:selectLockedCells] = "1" if opts[:select_locked_cells]
|
|
508
|
+
attrs[:sort] = "0" if opts[:sort] == false
|
|
509
|
+
attrs[:autoFilter] = "0" if opts[:auto_filter] == false
|
|
510
|
+
attrs[:pivotTables] = "0" if opts[:pivot_tables] == false
|
|
511
|
+
attrs[:selectUnlockedCells] = "1" if opts[:select_unlocked_cells]
|
|
512
|
+
attrs[:password] = opts[:password] if opts[:password]
|
|
513
|
+
attrs[:algorithmName] = opts[:algorithm_name] if opts[:algorithm_name]
|
|
514
|
+
attrs[:hashValue] = opts[:hash_value] if opts[:hash_value]
|
|
515
|
+
attrs[:saltValue] = opts[:salt_value] if opts[:salt_value]
|
|
516
|
+
attrs[:spinCount] = opts[:spin_count].to_s if opts[:spin_count]
|
|
517
|
+
@builder.empty_tag("sheetProtection", attrs)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def write_auto_filter(range, filter_columns, sort_state)
|
|
521
|
+
if (filter_columns && !filter_columns.empty?) || sort_state
|
|
522
|
+
@builder.open_tag("autoFilter", { ref: range })
|
|
523
|
+
filter_columns&.each do |col_id, filter|
|
|
524
|
+
write_filter_column(col_id, filter)
|
|
525
|
+
end
|
|
526
|
+
if sort_state
|
|
527
|
+
ss_attrs = { ref: sort_state[:ref] }
|
|
528
|
+
ss_attrs[:columnSort] = "1" if sort_state[:column_sort]
|
|
529
|
+
ss_attrs[:caseSensitive] = "1" if sort_state[:case_sensitive]
|
|
530
|
+
@builder.open_tag("sortState", ss_attrs)
|
|
531
|
+
(sort_state[:sort_conditions] || []).each do |sc|
|
|
532
|
+
sc_attrs = { ref: sc[:ref] }
|
|
533
|
+
sc_attrs[:descending] = "1" if sc[:descending]
|
|
534
|
+
@builder.empty_tag("sortCondition", sc_attrs)
|
|
535
|
+
end
|
|
536
|
+
@builder.close_tag("sortState")
|
|
537
|
+
end
|
|
538
|
+
@builder.close_tag("autoFilter")
|
|
539
|
+
else
|
|
540
|
+
@builder.empty_tag("autoFilter", { ref: range })
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def write_filter_column(col_id, filter)
|
|
545
|
+
@builder.open_tag("filterColumn", { colId: col_id.to_s })
|
|
546
|
+
case filter[:type]
|
|
547
|
+
when :filters
|
|
548
|
+
f_attrs = {}
|
|
549
|
+
f_attrs[:blank] = "1" if filter[:blank]
|
|
550
|
+
@builder.open_tag("filters", f_attrs)
|
|
551
|
+
(filter[:values] || []).each do |val|
|
|
552
|
+
@builder.empty_tag("filter", { val: val.to_s })
|
|
553
|
+
end
|
|
554
|
+
@builder.close_tag("filters")
|
|
555
|
+
when :custom
|
|
556
|
+
if filter[:filters]
|
|
557
|
+
c_attrs = {}
|
|
558
|
+
c_attrs[:and] = "1" if filter[:and]
|
|
559
|
+
@builder.open_tag("customFilters", c_attrs)
|
|
560
|
+
filter[:filters].each do |cf|
|
|
561
|
+
@builder.empty_tag("customFilter", { operator: cf[:operator], val: cf[:val].to_s })
|
|
562
|
+
end
|
|
563
|
+
else
|
|
564
|
+
@builder.open_tag("customFilters")
|
|
565
|
+
@builder.empty_tag("customFilter", { operator: filter[:operator], val: filter[:val].to_s })
|
|
566
|
+
end
|
|
567
|
+
@builder.close_tag("customFilters")
|
|
568
|
+
when :dynamic
|
|
569
|
+
@builder.empty_tag("dynamicFilter", { type: filter[:dynamic_type] })
|
|
570
|
+
when :top10
|
|
571
|
+
t_attrs = {}
|
|
572
|
+
t_attrs[:top] = filter[:top] ? "1" : "0" unless filter[:top].nil?
|
|
573
|
+
t_attrs[:percent] = filter[:percent] ? "1" : "0" unless filter[:percent].nil?
|
|
574
|
+
t_attrs[:val] = filter[:val].to_s if filter[:val]
|
|
575
|
+
@builder.empty_tag("top10", t_attrs)
|
|
576
|
+
end
|
|
577
|
+
@builder.close_tag("filterColumn")
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def write_merge_cells(ranges)
|
|
581
|
+
@builder.open_tag("mergeCells", { count: ranges.size.to_s })
|
|
582
|
+
ranges.each do |range|
|
|
583
|
+
@builder.empty_tag("mergeCell", { ref: range })
|
|
584
|
+
end
|
|
585
|
+
@builder.close_tag("mergeCells")
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def write_conditional_formatting(rules)
|
|
589
|
+
# Group rules by sqref
|
|
590
|
+
grouped = {}
|
|
591
|
+
rules.each do |rule|
|
|
592
|
+
sqref = rule[:sqref]
|
|
593
|
+
grouped[sqref] ||= []
|
|
594
|
+
grouped[sqref] << rule
|
|
595
|
+
end
|
|
596
|
+
grouped.each do |sqref, sqref_rules|
|
|
597
|
+
@builder.open_tag("conditionalFormatting", { sqref: sqref })
|
|
598
|
+
sqref_rules.each_with_index do |rule, idx|
|
|
599
|
+
type = rule[:type]
|
|
600
|
+
if type.is_a?(String) || type.is_a?(Symbol)
|
|
601
|
+
t_str = type.to_s
|
|
602
|
+
snake = t_str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
603
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
604
|
+
.downcase.to_sym
|
|
605
|
+
type = snake if %i[cell_is expression color_scale data_bar icon_set above_average top10 duplicate_values unique_values contains_text not_contains_text begins_with ends_with contains_blanks not_contains_blanks time_period].include?(snake)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
cf_type = case type
|
|
609
|
+
when :cell_is then "cellIs"
|
|
610
|
+
when :expression then "expression"
|
|
611
|
+
when :color_scale then "colorScale"
|
|
612
|
+
when :data_bar then "dataBar"
|
|
613
|
+
when :icon_set then "iconSet"
|
|
614
|
+
when :above_average then "aboveAverage"
|
|
615
|
+
when :top10 then "top10"
|
|
616
|
+
when :duplicate_values then "duplicateValues"
|
|
617
|
+
when :unique_values then "uniqueValues"
|
|
618
|
+
when :contains_text then "containsText"
|
|
619
|
+
when :not_contains_text then "notContainsText"
|
|
620
|
+
when :begins_with then "beginsWith"
|
|
621
|
+
when :ends_with then "endsWith"
|
|
622
|
+
when :contains_blanks then "containsBlanks"
|
|
623
|
+
when :not_contains_blanks then "notContainsBlanks"
|
|
624
|
+
when :time_period then "timePeriod"
|
|
625
|
+
else type.to_s
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
r_attrs = { type: cf_type, priority: (rule[:priority] || (idx + 1)).to_s }
|
|
629
|
+
r_attrs[:operator] = rule[:operator].to_s if rule[:operator]
|
|
630
|
+
r_attrs[:dxfId] = rule[:format_id].to_s if rule[:format_id]
|
|
631
|
+
r_attrs[:stopIfTrue] = "1" if rule[:stop_if_true]
|
|
632
|
+
r_attrs[:aboveAverage] = "0" if rule[:above_average] == false
|
|
633
|
+
r_attrs[:equalAverage] = "1" if rule[:equal_average]
|
|
634
|
+
r_attrs[:rank] = rule[:rank].to_s if rule[:rank]
|
|
635
|
+
r_attrs[:percent] = "1" if rule[:percent]
|
|
636
|
+
r_attrs[:bottom] = "1" if rule[:bottom]
|
|
637
|
+
r_attrs[:text] = rule[:text].to_s if rule[:text]
|
|
638
|
+
r_attrs[:timePeriod] = rule[:time_period].to_s if rule[:time_period]
|
|
639
|
+
r_attrs[:stdDev] = rule[:std_dev].to_s if rule[:std_dev]
|
|
640
|
+
|
|
641
|
+
case type
|
|
642
|
+
when :color_scale
|
|
643
|
+
@builder.open_tag("cfRule", r_attrs)
|
|
644
|
+
cs = rule[:color_scale] || {}
|
|
645
|
+
cfvos = cs[:cfvo] || [
|
|
646
|
+
{ type: "min" },
|
|
647
|
+
{ type: "percent", val: "50" },
|
|
648
|
+
{ type: "max" }
|
|
649
|
+
]
|
|
650
|
+
colors = cs[:colors] || [
|
|
651
|
+
"FFF8696B", # Red
|
|
652
|
+
"FFFFEB84", # Yellow
|
|
653
|
+
"FF63BE7B" # Green
|
|
654
|
+
]
|
|
655
|
+
@builder.tag("colorScale") do |b|
|
|
656
|
+
cfvos.each do |cfvo|
|
|
657
|
+
cfvo_attrs = { type: cfvo[:type] }
|
|
658
|
+
cfvo_attrs[:val] = cfvo[:val].to_s if cfvo[:val]
|
|
659
|
+
cfvo_attrs[:gte] = "0" if cfvo[:gte] == false
|
|
660
|
+
b.empty_tag("cfvo", cfvo_attrs)
|
|
661
|
+
end
|
|
662
|
+
colors.each do |c|
|
|
663
|
+
if c.is_a?(Hash)
|
|
664
|
+
c_attrs = {}
|
|
665
|
+
c_attrs[:auto] = "1" if c[:auto]
|
|
666
|
+
c_attrs[:indexed] = c[:indexed].to_s if c[:indexed]
|
|
667
|
+
c_attrs[:rgb] = c[:rgb] if c[:rgb]
|
|
668
|
+
c_attrs[:theme] = c[:theme].to_s if c[:theme]
|
|
669
|
+
c_attrs[:tint] = c[:tint].to_s if c[:tint]
|
|
670
|
+
b.empty_tag("color", c_attrs)
|
|
671
|
+
else
|
|
672
|
+
b.empty_tag("color", { rgb: c.to_s })
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
@builder.close_tag("cfRule")
|
|
677
|
+
when :data_bar
|
|
678
|
+
@builder.open_tag("cfRule", r_attrs)
|
|
679
|
+
db = rule[:data_bar] || {}
|
|
680
|
+
db_color = db[:color] || rule[:color] || "FF5A8DD4"
|
|
681
|
+
cfvos = db[:cfvo] || [
|
|
682
|
+
{ type: "min" },
|
|
683
|
+
{ type: "max" }
|
|
684
|
+
]
|
|
685
|
+
db_attrs = {}
|
|
686
|
+
db_attrs[:minLength] = db[:min_length].to_s if db[:min_length]
|
|
687
|
+
db_attrs[:maxLength] = db[:max_length].to_s if db[:max_length]
|
|
688
|
+
db_attrs[:showValue] = db[:show_value] ? "1" : "0" unless db[:show_value].nil?
|
|
689
|
+
|
|
690
|
+
@builder.tag("dataBar", db_attrs) do |b|
|
|
691
|
+
cfvos.each do |cfvo|
|
|
692
|
+
cfvo_attrs = { type: cfvo[:type] }
|
|
693
|
+
cfvo_attrs[:val] = cfvo[:val].to_s if cfvo[:val]
|
|
694
|
+
cfvo_attrs[:gte] = "0" if cfvo[:gte] == false
|
|
695
|
+
b.empty_tag("cfvo", cfvo_attrs)
|
|
696
|
+
end
|
|
697
|
+
if db_color.is_a?(Hash)
|
|
698
|
+
c_attrs = {}
|
|
699
|
+
c_attrs[:rgb] = db_color[:rgb] if db_color[:rgb]
|
|
700
|
+
c_attrs[:theme] = db_color[:theme].to_s if db_color[:theme]
|
|
701
|
+
b.empty_tag("color", c_attrs)
|
|
702
|
+
else
|
|
703
|
+
b.empty_tag("color", { rgb: db_color.to_s })
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
@builder.close_tag("cfRule")
|
|
707
|
+
when :icon_set
|
|
708
|
+
@builder.open_tag("cfRule", r_attrs)
|
|
709
|
+
is = rule[:icon_set] || {}
|
|
710
|
+
icon_style = is[:icon_set] || rule[:icon_style] || "3Arrows"
|
|
711
|
+
cfvos = is[:cfvo] || [
|
|
712
|
+
{ type: "percent", val: "0" },
|
|
713
|
+
{ type: "percent", val: "33" },
|
|
714
|
+
{ type: "percent", val: "67" }
|
|
715
|
+
]
|
|
716
|
+
is_attrs = { iconSet: icon_style }
|
|
717
|
+
is_attrs[:reverse] = is[:reverse] ? "1" : "0" unless is[:reverse].nil?
|
|
718
|
+
is_attrs[:percent] = is[:percent] ? "1" : "0" unless is[:percent].nil?
|
|
719
|
+
is_attrs[:showValue] = is[:show_value] ? "1" : "0" unless is[:show_value].nil?
|
|
720
|
+
|
|
721
|
+
@builder.tag("iconSet", is_attrs) do |b|
|
|
722
|
+
cfvos.each do |cfvo|
|
|
723
|
+
cfvo_attrs = { type: cfvo[:type] }
|
|
724
|
+
cfvo_attrs[:val] = cfvo[:val].to_s if cfvo[:val]
|
|
725
|
+
cfvo_attrs[:gte] = "0" if cfvo[:gte] == false
|
|
726
|
+
b.empty_tag("cfvo", cfvo_attrs)
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
@builder.close_tag("cfRule")
|
|
730
|
+
else
|
|
731
|
+
formulas = rule[:formulas] || [rule[:formula]].compact
|
|
732
|
+
if formulas.empty?
|
|
733
|
+
@builder.empty_tag("cfRule", r_attrs)
|
|
734
|
+
else
|
|
735
|
+
@builder.open_tag("cfRule", r_attrs)
|
|
736
|
+
formulas.each do |f|
|
|
737
|
+
@builder.tag("formula") { |b| b.text(f) }
|
|
738
|
+
end
|
|
739
|
+
@builder.close_tag("cfRule")
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
@builder.close_tag("conditionalFormatting")
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def write_data_validations(validations)
|
|
748
|
+
@builder.open_tag("dataValidations", { count: validations.size.to_s })
|
|
749
|
+
validations.each do |dv|
|
|
750
|
+
dv_attrs = { sqref: dv[:sqref] }
|
|
751
|
+
dv_type = dv[:type]
|
|
752
|
+
dv_attrs[:type] = dv_type.to_s if dv_type
|
|
753
|
+
dv_attrs[:operator] = dv[:operator].to_s if dv[:operator]
|
|
754
|
+
dv_attrs[:allowBlank] = "1" if dv[:allow_blank]
|
|
755
|
+
dv_attrs[:showInputMessage] = "1" if dv[:show_input_message]
|
|
756
|
+
dv_attrs[:showErrorMessage] = "1" if dv[:show_error_message]
|
|
757
|
+
dv_attrs[:errorStyle] = dv[:error_style].to_s if dv[:error_style]
|
|
758
|
+
dv_attrs[:errorTitle] = dv[:error_title] if dv[:error_title]
|
|
759
|
+
dv_attrs[:error] = dv[:error] if dv[:error]
|
|
760
|
+
dv_attrs[:promptTitle] = dv[:prompt_title] if dv[:prompt_title]
|
|
761
|
+
dv_attrs[:prompt] = dv[:prompt] if dv[:prompt]
|
|
762
|
+
|
|
763
|
+
has_formulas = dv[:formula1] || dv[:formula2]
|
|
764
|
+
if has_formulas
|
|
765
|
+
@builder.open_tag("dataValidation", dv_attrs)
|
|
766
|
+
@builder.tag("formula1") { |b| b.text(dv[:formula1].to_s) } if dv[:formula1]
|
|
767
|
+
@builder.tag("formula2") { |b| b.text(dv[:formula2].to_s) } if dv[:formula2]
|
|
768
|
+
@builder.close_tag("dataValidation")
|
|
769
|
+
else
|
|
770
|
+
@builder.empty_tag("dataValidation", dv_attrs)
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
@builder.close_tag("dataValidations")
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
def write_hyperlinks(links)
|
|
777
|
+
@builder.open_tag("hyperlinks")
|
|
778
|
+
links.each_with_index do |link, idx|
|
|
779
|
+
h_attrs = { ref: link[:cell] }
|
|
780
|
+
h_attrs[:"r:id"] = "rId#{link[:_rid] || (idx + 1)}" if link[:url]
|
|
781
|
+
h_attrs[:location] = link[:location] if link[:location]
|
|
782
|
+
h_attrs[:display] = link[:display] if link[:display]
|
|
783
|
+
h_attrs[:tooltip] = link[:tooltip] if link[:tooltip]
|
|
784
|
+
@builder.empty_tag("hyperlink", h_attrs)
|
|
785
|
+
end
|
|
786
|
+
@builder.close_tag("hyperlinks")
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def write_print_options(opts)
|
|
790
|
+
attrs = {}
|
|
791
|
+
attrs[:gridLines] = "1" if opts[:grid_lines]
|
|
792
|
+
attrs[:headings] = "1" if opts[:headings]
|
|
793
|
+
attrs[:horizontalCentered] = "1" if opts[:horizontal_centered]
|
|
794
|
+
attrs[:verticalCentered] = "1" if opts[:vertical_centered]
|
|
795
|
+
@builder.empty_tag("printOptions", attrs) unless attrs.empty?
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def write_page_margins(margins)
|
|
799
|
+
attrs = {
|
|
800
|
+
left: (margins[:left] || 0.7).to_s,
|
|
801
|
+
right: (margins[:right] || 0.7).to_s,
|
|
802
|
+
top: (margins[:top] || 0.75).to_s,
|
|
803
|
+
bottom: (margins[:bottom] || 0.75).to_s,
|
|
804
|
+
header: (margins[:header] || 0.3).to_s,
|
|
805
|
+
footer: (margins[:footer] || 0.3).to_s
|
|
806
|
+
}
|
|
807
|
+
@builder.empty_tag("pageMargins", attrs)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def write_page_setup(opts)
|
|
811
|
+
attrs = {}
|
|
812
|
+
attrs[:orientation] = opts[:orientation].to_s if opts[:orientation]
|
|
813
|
+
attrs[:paperSize] = opts[:paper_size].to_s if opts[:paper_size]
|
|
814
|
+
attrs[:scale] = opts[:scale].to_s if opts[:scale]
|
|
815
|
+
attrs[:fitToWidth] = opts[:fit_to_width].to_s if opts[:fit_to_width]
|
|
816
|
+
attrs[:fitToHeight] = opts[:fit_to_height].to_s if opts[:fit_to_height]
|
|
817
|
+
attrs[:firstPageNumber] = opts[:first_page_number].to_s if opts[:first_page_number]
|
|
818
|
+
attrs[:pageOrder] = opts[:page_order].to_s if opts[:page_order]
|
|
819
|
+
attrs[:blackAndWhite] = "1" if opts[:black_and_white]
|
|
820
|
+
attrs[:draft] = "1" if opts[:draft]
|
|
821
|
+
@builder.empty_tag("pageSetup", attrs) unless attrs.empty?
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def write_header_footer(opts)
|
|
825
|
+
@builder.open_tag("headerFooter")
|
|
826
|
+
@builder.tag("oddHeader") { |b| b.text(opts[:odd_header]) } if opts[:odd_header]
|
|
827
|
+
@builder.tag("oddFooter") { |b| b.text(opts[:odd_footer]) } if opts[:odd_footer]
|
|
828
|
+
@builder.tag("evenHeader") { |b| b.text(opts[:even_header]) } if opts[:even_header]
|
|
829
|
+
@builder.tag("evenFooter") { |b| b.text(opts[:even_footer]) } if opts[:even_footer]
|
|
830
|
+
@builder.close_tag("headerFooter")
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def write_row_breaks(breaks)
|
|
834
|
+
@builder.open_tag("rowBreaks", { count: breaks.size.to_s, manualBreakCount: breaks.size.to_s })
|
|
835
|
+
breaks.each do |brk|
|
|
836
|
+
@builder.empty_tag("brk", { id: brk.to_s, max: "16383", man: "1" })
|
|
837
|
+
end
|
|
838
|
+
@builder.close_tag("rowBreaks")
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def write_col_breaks(breaks)
|
|
842
|
+
@builder.open_tag("colBreaks", { count: breaks.size.to_s, manualBreakCount: breaks.size.to_s })
|
|
843
|
+
breaks.each do |brk|
|
|
844
|
+
@builder.empty_tag("brk", { id: brk.to_s, max: "1048575", man: "1" })
|
|
845
|
+
end
|
|
846
|
+
@builder.close_tag("colBreaks")
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
def write_table_parts(tables, start_rid)
|
|
850
|
+
@builder.open_tag("tableParts", { count: tables.size.to_s })
|
|
851
|
+
tables.each_with_index do |_tbl, idx|
|
|
852
|
+
rid = start_rid ? "rId#{start_rid + idx}" : "rId#{idx + 1}"
|
|
853
|
+
@builder.empty_tag("tablePart", { "r:id": rid })
|
|
854
|
+
end
|
|
855
|
+
@builder.close_tag("tableParts")
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# --- Sparklines (extLst) ---
|
|
859
|
+
|
|
860
|
+
def write_sparklines(groups)
|
|
861
|
+
@builder.open_tag("extLst")
|
|
862
|
+
@builder.open_tag("ext", {
|
|
863
|
+
uri: "{05C60535-1F16-4fd2-B633-F4F36F0B64E0}",
|
|
864
|
+
"xmlns:x14": "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"
|
|
865
|
+
})
|
|
866
|
+
@builder.open_tag("x14:sparklineGroups", {
|
|
867
|
+
"xmlns:xm": "http://schemas.microsoft.com/office/excel/2006/main"
|
|
868
|
+
})
|
|
869
|
+
groups.each do |group|
|
|
870
|
+
sg_attrs = {}
|
|
871
|
+
sg_attrs[:type] = group[:type] if group[:type]
|
|
872
|
+
sg_attrs[:displayEmptyCellsAs] = group[:display_empty] if group[:display_empty]
|
|
873
|
+
sg_attrs[:markers] = "1" if group[:markers]
|
|
874
|
+
sg_attrs[:high] = "1" if group[:high]
|
|
875
|
+
sg_attrs[:low] = "1" if group[:low]
|
|
876
|
+
sg_attrs[:first] = "1" if group[:first]
|
|
877
|
+
sg_attrs[:last] = "1" if group[:last]
|
|
878
|
+
sg_attrs[:negative] = "1" if group[:negative]
|
|
879
|
+
sg_attrs[:manualMax] = group[:max].to_s if group[:max]
|
|
880
|
+
sg_attrs[:manualMin] = group[:min].to_s if group[:min]
|
|
881
|
+
sg_attrs[:lineWeight] = group[:line_weight].to_s if group[:line_weight]
|
|
882
|
+
|
|
883
|
+
@builder.open_tag("x14:sparklineGroup", sg_attrs)
|
|
884
|
+
|
|
885
|
+
write_sparkline_color("x14:colorSeries", group[:color_series]) if group[:color_series]
|
|
886
|
+
write_sparkline_color("x14:colorNegative", group[:color_negative]) if group[:color_negative]
|
|
887
|
+
write_sparkline_color("x14:colorAxis", group[:color_axis]) if group[:color_axis]
|
|
888
|
+
write_sparkline_color("x14:colorMarkers", group[:color_markers]) if group[:color_markers]
|
|
889
|
+
write_sparkline_color("x14:colorFirst", group[:color_first]) if group[:color_first]
|
|
890
|
+
write_sparkline_color("x14:colorLast", group[:color_last]) if group[:color_last]
|
|
891
|
+
write_sparkline_color("x14:colorHigh", group[:color_high]) if group[:color_high]
|
|
892
|
+
write_sparkline_color("x14:colorLow", group[:color_low]) if group[:color_low]
|
|
893
|
+
|
|
894
|
+
@builder.open_tag("x14:sparklines")
|
|
895
|
+
sparklines = group[:sparklines] || []
|
|
896
|
+
sparklines.each do |sp|
|
|
897
|
+
@builder.open_tag("x14:sparkline")
|
|
898
|
+
@builder.open_tag("xm:f")
|
|
899
|
+
@builder.text(sp[:data_ref])
|
|
900
|
+
@builder.close_tag("xm:f")
|
|
901
|
+
@builder.open_tag("xm:sqref")
|
|
902
|
+
@builder.text(sp[:location_ref])
|
|
903
|
+
@builder.close_tag("xm:sqref")
|
|
904
|
+
@builder.close_tag("x14:sparkline")
|
|
905
|
+
end
|
|
906
|
+
@builder.close_tag("x14:sparklines")
|
|
907
|
+
@builder.close_tag("x14:sparklineGroup")
|
|
908
|
+
end
|
|
909
|
+
@builder.close_tag("x14:sparklineGroups")
|
|
910
|
+
@builder.close_tag("ext")
|
|
911
|
+
@builder.close_tag("extLst")
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def write_sparkline_color(tag, color)
|
|
915
|
+
attrs = {}
|
|
916
|
+
if color.is_a?(String)
|
|
917
|
+
attrs[:rgb] = color
|
|
918
|
+
else
|
|
919
|
+
attrs[:rgb] = color[:rgb] if color[:rgb]
|
|
920
|
+
attrs[:theme] = color[:theme].to_s if color[:theme]
|
|
921
|
+
attrs[:tint] = color[:tint].to_s if color[:tint]
|
|
922
|
+
end
|
|
923
|
+
@builder.empty_tag(tag, attrs)
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# --- Columns ---
|
|
927
|
+
|
|
928
|
+
def write_columns(columns)
|
|
929
|
+
@builder.open_tag("cols")
|
|
930
|
+
columns.each do |col|
|
|
931
|
+
attrs = {
|
|
932
|
+
min: ((col[:index] || col[:min] || 0) + 1).to_s,
|
|
933
|
+
max: ((col[:index] || col[:max] || col[:min] || 0) + 1).to_s
|
|
934
|
+
}
|
|
935
|
+
attrs[:width] = col[:width].to_s if col[:width]
|
|
936
|
+
attrs[:hidden] = "1" if col[:hidden]
|
|
937
|
+
attrs[:customWidth] = "1" if col[:custom_width] || col[:width]
|
|
938
|
+
attrs[:outlineLevel] = col[:outline_level].to_s if col[:outline_level]
|
|
939
|
+
@builder.empty_tag("col", attrs)
|
|
940
|
+
end
|
|
941
|
+
@builder.close_tag("cols")
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def write_cell(cell)
|
|
945
|
+
ref = cell[:ref] || cell_ref(cell[:row_index], cell[:column_index])
|
|
946
|
+
attrs = { r: ref }
|
|
947
|
+
|
|
948
|
+
value = cell[:value]
|
|
949
|
+
formula = cell[:formula]
|
|
950
|
+
# For formula cells with cached values, don't infer type from value
|
|
951
|
+
type = formula ? cell[:type] : (cell[:type] || cell_type(value))
|
|
952
|
+
attrs[:t] = type if type
|
|
953
|
+
attrs[:s] = cell[:style_index].to_s if cell[:style_index]
|
|
954
|
+
|
|
955
|
+
if value.nil? && formula.nil?
|
|
956
|
+
@builder.empty_tag("c", attrs)
|
|
957
|
+
return
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
@builder.open_tag("c", attrs)
|
|
961
|
+
if formula
|
|
962
|
+
f_attrs = {}
|
|
963
|
+
f_attrs[:ca] = "1" if cell[:formula_ca]
|
|
964
|
+
if f_attrs.empty?
|
|
965
|
+
@builder.tag("f") { |b| b.text(formula) }
|
|
966
|
+
else
|
|
967
|
+
@builder.tag("f", f_attrs) { |b| b.text(formula) }
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
@builder.tag("v") { |b| b.text(xml_cell_value(value, type)) } unless value.nil?
|
|
971
|
+
@builder.close_tag("c")
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def cell_type(value)
|
|
975
|
+
case value
|
|
976
|
+
when String then "s" # will be shared string index
|
|
977
|
+
when true, false then "b"
|
|
978
|
+
end
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def xml_cell_value(value, _type)
|
|
982
|
+
case value
|
|
983
|
+
when true then "1"
|
|
984
|
+
when false then "0"
|
|
985
|
+
else value.to_s
|
|
986
|
+
end
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
def cell_ref(row_index, col_index)
|
|
990
|
+
col_letter = column_letter(col_index)
|
|
991
|
+
"#{col_letter}#{row_index + 1}"
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
def column_letter(index)
|
|
995
|
+
Xlsxrb::Elements::Cell.column_letter(index)
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
ESCAPE_RE = /[&<>"']/
|
|
999
|
+
ESCAPE_MAP = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.freeze
|
|
1000
|
+
private_constant :ESCAPE_RE, :ESCAPE_MAP
|
|
1001
|
+
|
|
1002
|
+
def escape_xml(value)
|
|
1003
|
+
str = value.to_s
|
|
1004
|
+
str.match?(ESCAPE_RE) ? str.gsub(ESCAPE_RE, ESCAPE_MAP) : str
|
|
1005
|
+
end
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
end
|