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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +64 -0
  3. data/.devcontainer/devcontainer.json +17 -0
  4. data/CHANGELOG.md +12 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +384 -0
  8. data/Rakefile +290 -0
  9. data/benchmark.rb +390 -0
  10. data/docs/ARCHITECTURE.md +488 -0
  11. data/docs/SPEC_SOURCES.md +42 -0
  12. data/docs/visual/VisualGallery.md +4684 -0
  13. data/docs/wasm/wasm_doc_helper.css +189 -0
  14. data/docs/wasm/wasm_doc_helper.js +320 -0
  15. data/lib/xlsxrb/elements/cell.rb +77 -0
  16. data/lib/xlsxrb/elements/column.rb +28 -0
  17. data/lib/xlsxrb/elements/row.rb +47 -0
  18. data/lib/xlsxrb/elements/types.rb +36 -0
  19. data/lib/xlsxrb/elements/workbook.rb +47 -0
  20. data/lib/xlsxrb/elements/worksheet.rb +50 -0
  21. data/lib/xlsxrb/elements.rb +15 -0
  22. data/lib/xlsxrb/ooxml/reader.rb +8048 -0
  23. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
  24. data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
  25. data/lib/xlsxrb/ooxml/utils.rb +122 -0
  26. data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
  27. data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
  28. data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
  29. data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
  30. data/lib/xlsxrb/ooxml/writer.rb +5450 -0
  31. data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
  32. data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
  33. data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
  34. data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
  35. data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
  36. data/lib/xlsxrb/ooxml.rb +22 -0
  37. data/lib/xlsxrb/style_builder.rb +241 -0
  38. data/lib/xlsxrb/version.rb +5 -0
  39. data/lib/xlsxrb.rb +1427 -0
  40. data/measure_memory.rb +42 -0
  41. data/sig/xlsxrb.rbs +23 -0
  42. data/vendor/sdk_runner/Program.cs +91 -0
  43. data/vendor/sdk_runner/sdk_runner.csproj +13 -0
  44. 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 = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&apos;" }.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