xlsxrb 0.1.9 → 0.1.10

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +71 -253
  4. data/Rakefile +2 -1
  5. data/docs/PEER_LIBRARIES.md +121 -0
  6. data/docs/assets/benchmark_results.svg +144 -0
  7. data/docs/assets/lsp_autocompletion.png +0 -0
  8. data/docs/assets/playground_preview.png +0 -0
  9. data/docs/visual/VisualGallery.md +183 -0
  10. data/docs/wasm/ruby.wasm +0 -0
  11. data/lib/xlsxrb/chart_builder.rb +333 -0
  12. data/lib/xlsxrb/elements/workbook.rb +2 -2
  13. data/lib/xlsxrb/ooxml/reader/listeners.rb +6450 -0
  14. data/lib/xlsxrb/ooxml/reader.rb +2 -6438
  15. data/lib/xlsxrb/ooxml/workbook_writer.rb +10 -10
  16. data/lib/xlsxrb/ooxml/worksheet_parser.rb +161 -116
  17. data/lib/xlsxrb/ooxml/worksheet_writer.rb +5 -1
  18. data/lib/xlsxrb/stream_row.rb +5 -3
  19. data/lib/xlsxrb/stream_sheet.rb +125 -0
  20. data/lib/xlsxrb/stream_writer.rb +1646 -0
  21. data/lib/xlsxrb/version.rb +1 -1
  22. data/lib/xlsxrb/workbook_builder.rb +309 -0
  23. data/lib/xlsxrb/worksheet_builder.rb +691 -0
  24. data/lib/xlsxrb.rb +238 -2968
  25. data/sig/generated/xlsxrb/chart_builder.rbs +269 -0
  26. data/sig/generated/xlsxrb/ooxml/reader/listeners.rbs +1074 -0
  27. data/sig/generated/xlsxrb/ooxml/reader.rbs +0 -1067
  28. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +3 -3
  29. data/sig/generated/xlsxrb/stream_row.rbs +3 -2
  30. data/sig/generated/xlsxrb/stream_sheet.rbs +100 -0
  31. data/sig/generated/xlsxrb/stream_writer.rbs +954 -0
  32. data/sig/generated/xlsxrb/workbook_builder.rbs +146 -0
  33. data/sig/generated/xlsxrb/worksheet_builder.rbs +349 -0
  34. data/sig/generated/xlsxrb.rbs +103 -1563
  35. metadata +17 -1
@@ -0,0 +1,1646 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require "tempfile"
6
+ require_relative "style_builder"
7
+ require_relative "chart_builder"
8
+ require_relative "ooxml/writer"
9
+ require_relative "ooxml/worksheet_writer"
10
+ require_relative "ooxml/workbook_writer"
11
+
12
+ module Xlsxrb
13
+ # High-performance streaming writer that outputs XLSX files with O(1) constant memory.
14
+ #
15
+ # @example Streaming write using StreamWriter
16
+ # Xlsxrb.write("output.xlsx") do |writer|
17
+ # writer.sheet("Sales") do |s|
18
+ # s.row(["Item", "Price"])
19
+ # s.row(["Coffee", 3.50])
20
+ # end
21
+ # end
22
+ #
23
+ # @api public
24
+ class StreamWriter
25
+ # @return [String, nil] Name of the currently active worksheet.
26
+ #: String?
27
+ attr_reader :current_sheet
28
+
29
+ # Initializes a streaming writer context.
30
+ #
31
+ # @param target [String, IO, StringIO] Destination file path or writable IO stream.
32
+ # @param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specification limits.
33
+ #: (untyped target, ?strict_excel_mode: bool) -> void
34
+ def initialize(target, strict_excel_mode: true)
35
+ @target = target
36
+ @strict_excel_mode = strict_excel_mode
37
+ @sst = []
38
+ @sst_index = {}
39
+ @sheets = []
40
+ @current_sheet = nil
41
+ @current_row_index = 0
42
+ @tempfiles = []
43
+ @current_tempfile = nil
44
+ @current_row_writer = nil
45
+ @current_columns = []
46
+ @current_charts = []
47
+ @current_hyperlinks = []
48
+ @current_auto_filter = nil
49
+ @current_filter_columns = {}
50
+ @current_sort_state = nil
51
+ @current_data_validations = []
52
+ @current_conditional_formats = []
53
+ @current_tables = []
54
+ @current_comments = []
55
+ @current_merge_cells = []
56
+ @current_freeze_pane = nil
57
+ @current_split_pane = nil
58
+ @current_selection = nil
59
+ @current_page_margins = nil
60
+ @current_page_setup = {}
61
+ @current_header_footer = {}
62
+ @current_print_options = {}
63
+ @current_sheet_protection = nil
64
+ @current_images = []
65
+ @current_shapes = []
66
+ @current_sheet_properties = {}
67
+ @current_sheet_view = {}
68
+ @current_row_breaks = []
69
+ @current_col_breaks = []
70
+ @styles = {} # { style_name => StyleBuilder }
71
+ @style_writer = Ooxml::Writer.new
72
+ @style_name_to_id = {}
73
+ # Workbook-level settings
74
+ @defined_names = []
75
+ @core_properties = {}
76
+ @app_properties = {}
77
+ @custom_properties = []
78
+ @workbook_protection = nil
79
+ @workbook_properties = { update_links: "never" }
80
+ end
81
+
82
+ # Sets a workbook property.
83
+ #
84
+ # @note **SECURITY WARNING:** If you set `:update_links` to anything other than `"never"`,
85
+ # you may expose end-users to malicious external reference vulnerabilities (e.g., CSV/DDE Injection)
86
+ # when they open the generated Excel file. Ensure you fully trust the exported data.
87
+ #
88
+ # @param name [Symbol] Property name (e.g. :update_links).
89
+ # @param value [String, Integer, Boolean] Property value.
90
+ # @return [void]
91
+ #: (Symbol name, String | Integer | bool value) -> void
92
+ def workbook_property(name, value)
93
+ @workbook_properties[name] = value
94
+ end
95
+
96
+ # Defines or configures a named cell style.
97
+ #
98
+ # @param name [String, Symbol] The name of the style.
99
+ # @param opts [Hash] Style options (e.g. bold: true, fill_color: "FF0000").
100
+ # @yield [style_builder]
101
+ # @yieldparam style_builder [Xlsxrb::StyleBuilder]
102
+ # @return [StyleBuilder]
103
+ #: (String | Symbol name, **untyped opts) ?{ (StyleBuilder) -> void } -> StyleBuilder
104
+ def style(name, **opts)
105
+ style_name = name.to_s
106
+ style_builder = StyleBuilder.new(style_name)
107
+ style_builder.apply_options!(**opts) unless opts.empty?
108
+ yield style_builder if block_given?
109
+ @styles[style_name] = style_builder
110
+
111
+ # Register immediately with low-level style writer
112
+ @style_name_to_id[style_name] = style_builder.register_with(@style_writer)
113
+
114
+ style_builder
115
+ end
116
+
117
+ # Proxy object yielded by the `sheet` method to prevent writing to inactive sheets in streaming mode.
118
+ #
119
+ # @api public
120
+ class WorksheetProxy
121
+ # @param writer [StreamWriter]
122
+ # @param sheet_name [String]
123
+ def initialize(writer, sheet_name)
124
+ @writer = writer
125
+ @sheet_name = sheet_name
126
+ end
127
+
128
+ # Defines or configures a named cell style on the active sheet.
129
+ #
130
+ # @example
131
+ # s.style(:header, bold: true, fill_color: "4F81BD", font_color: "FFFFFF")
132
+ #
133
+ # @param name [String, Symbol] The name of the style.
134
+ # @param opts [Hash] Style options (e.g. bold: true, fill_color: "FF0000").
135
+ # @yield [style_builder]
136
+ # @yieldparam style_builder [Xlsxrb::StyleBuilder]
137
+ # @return [Xlsxrb::StyleBuilder]
138
+ # @api public
139
+ #: (String | Symbol name, **untyped opts) ?{ (Xlsxrb::StyleBuilder) -> void } -> Xlsxrb::StyleBuilder
140
+ def style(...)
141
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
142
+
143
+ @writer.style(...)
144
+ end
145
+
146
+ # Merges a range of cells into a single cell.
147
+ #
148
+ # @example Merge with cell reference string
149
+ # s.merge("A1:C1")
150
+ #
151
+ # @example Merge with coordinates
152
+ # s.merge(row: 0, col_start: 0, col_end: 2)
153
+ #
154
+ # @param range [String, nil] The cell range (e.g. "A1:B2").
155
+ # @param row [Integer, nil] 0-based row index.
156
+ # @param col_start [Integer, String, nil] 0-based start column index or letter.
157
+ # @param col_end [Integer, String, nil] 0-based end column index or letter.
158
+ # @param row_start [Integer, nil] 0-based start row index.
159
+ # @param row_end [Integer, nil] 0-based end row index.
160
+ # @return [void]
161
+ # @api public
162
+ #: (?String? range, ?row: Integer | nil, ?col_start: (Integer | String)?, ?col_end: (Integer | String)?, ?row_start: Integer | nil, ?row_end: Integer | nil) -> void
163
+ def merge(...)
164
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
165
+
166
+ @writer.merge(...)
167
+ end
168
+
169
+ # Adds a drawing shape to the sheet.
170
+ #
171
+ # @example
172
+ # s.shape(preset: "ellipse", text: "Circle", from_col: 1, from_row: 1, to_col: 4, to_row: 5)
173
+ #
174
+ # @param preset [String] Preset shape type (e.g. "rect", "ellipse").
175
+ # @param text [String, nil] Shape label text.
176
+ # @param from_col [Integer] Starting column index (0-based).
177
+ # @param from_row [Integer] Starting row index (0-based).
178
+ # @param to_col [Integer] Ending column index (0-based).
179
+ # @param to_row [Integer] Ending row index (0-based).
180
+ # @param opts [Hash] Additional shape formatting options.
181
+ # @return [void]
182
+ # @api public
183
+ #: (?preset: String, ?text: String?, ?from_col: Integer, ?from_row: Integer, ?to_col: Integer, ?to_row: Integer, **untyped opts) -> void
184
+ def shape(...)
185
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
186
+
187
+ @writer.shape(...)
188
+ end
189
+
190
+ # simplecov:disable
191
+ # Edge case / untested delegation block
192
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
193
+ def internal_sheet_setup(...)
194
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
195
+
196
+ @writer.internal_sheet_setup(...)
197
+ end
198
+ # simplecov:enable
199
+
200
+ # Appends a row of cells to the active sheet.
201
+ #
202
+ # @example Write an array of values
203
+ # s.row(["Name", "Age", "City"])
204
+ #
205
+ # @example Write with explicit column keys and styles
206
+ # s.row({ A: "Header", C: 100 }, styles: { A: :bold })
207
+ #
208
+ # @param values [Array, Hash] The cell values (e.g. `[1, 2, 3]` or `{ A: 1, C: 3 }`).
209
+ # @param styles [String, Symbol, Array, Hash, nil] Style names or hashes to apply.
210
+ # @param height [Float, Integer, nil] The row height in points (0 - 409).
211
+ # @param hidden [Boolean] Whether the row is hidden.
212
+ # @param custom_height [Boolean] Whether to flag as custom height.
213
+ # @param outline_level [Integer, nil] Grouping/outline level (0 - 7).
214
+ # @return [void]
215
+ # @api public
216
+ #: (Array[untyped] | Hash[untyped, untyped] values, ?styles: untyped, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil) -> void
217
+ def row(...)
218
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
219
+
220
+ @writer.row(...)
221
+ end
222
+
223
+ # Configures column width and properties.
224
+ #
225
+ # @example Set column A width
226
+ # s.column(0, width: 25.0)
227
+ #
228
+ # @param col_index [Integer, String, Symbol] 0-based column index or letter (e.g. 0 or "A" or :A).
229
+ # @param width [Float, Integer, nil] Column width in characters.
230
+ # @param hidden [Boolean] Whether the column is hidden.
231
+ # @param best_fit [Boolean] Whether the column automatically fits content.
232
+ # @param custom_width [Boolean] Whether to flag as custom width.
233
+ # @param outline_level [Integer, nil] Grouping/outline level (0 - 7).
234
+ # @param collapsed [Boolean] Whether the outline group is collapsed.
235
+ # @return [void]
236
+ # @api public
237
+ #: (Integer | String | Symbol | Range[Integer | String] | Array[Integer | String] col_index, ?width: Float | Integer | nil, ?hidden: bool, ?best_fit: bool, ?custom_width: bool, ?outline_level: Integer | nil, ?collapsed: bool) -> void
238
+ def column(...)
239
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
240
+
241
+ @writer.column(...)
242
+ end
243
+
244
+ # Adds a chart to the active sheet.
245
+ #
246
+ # @example
247
+ # s.chart(:bar) do |chart_builder|
248
+ # chart_builder.title("Quarterly Sales")
249
+ # chart_builder.series(values: "Sheet1!$B$2:$B$5", categories: "Sheet1!$A$2:$A$5", name: "Revenue")
250
+ # end
251
+ #
252
+ # @param type [Symbol, String, nil] The chart type (:bar, :col, :line, :pie, :scatter, :area, :doughnut, :radar).
253
+ # @param opts [Hash] Additional chart options.
254
+ # @yield [chart_builder]
255
+ # @yieldparam chart_builder [Xlsxrb::ChartBuilder]
256
+ # @return [void]
257
+ # @api public
258
+ #: (?Symbol | String? type, **untyped opts) ?{ (Xlsxrb::ChartBuilder) -> void } -> void
259
+ def chart(...)
260
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
261
+
262
+ @writer.chart(...)
263
+ end
264
+
265
+ # Adds a hyperlink to a cell.
266
+ #
267
+ # @example Positional URL
268
+ # s.hyperlink("A1", "https://example.com", display: "Example")
269
+ #
270
+ # @example Keyword location
271
+ # s.hyperlink("A1", location: "https://example.com", tooltip: "Go to Example")
272
+ #
273
+ # @param cell [String] The cell reference (e.g. "A1").
274
+ # @param url [String, nil] The target URL or URI.
275
+ # @param display [String, nil] Display text for the link.
276
+ # @param tooltip [String, nil] Tooltip text when hovering.
277
+ # @param location [String, nil] Destination location / URL (keyword alternative).
278
+ # @return [void]
279
+ # @api public
280
+ #: (String cell, ?String? url, ?display: String?, ?tooltip: String?, ?location: String?) -> void
281
+ def hyperlink(...)
282
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
283
+
284
+ @writer.hyperlink(...)
285
+ end
286
+
287
+ # Sets the auto-filter range on the active sheet.
288
+ #
289
+ # @example
290
+ # s.auto_filter("A1:D100")
291
+ #
292
+ # @param ref [String] The cell range (e.g. "A1:D10").
293
+ # @return [void]
294
+ # @api public
295
+ #: (String ref) -> void
296
+ def auto_filter(...)
297
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
298
+
299
+ @writer.auto_filter(...)
300
+ end
301
+
302
+ # Sets filter criteria for a column in the auto-filter.
303
+ #
304
+ # @example Simple values filter
305
+ # s.filter_column(0, ["Active", "Pending"])
306
+ #
307
+ # @param col_id [Integer] 0-based column index relative to auto-filter range.
308
+ # @param filter_values [Array<String>, Hash] Values or filter specification hash.
309
+ # @return [void]
310
+ # @api public
311
+ #: (Integer col_id, Array[String] | Hash[Symbol, untyped] filter_values) -> void
312
+ def filter_column(...)
313
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
314
+
315
+ @writer.filter_column(...)
316
+ end
317
+
318
+ # Configures sort state on a range.
319
+ #
320
+ # @example
321
+ # s.sort_state("A1:A10", [{ ref: "A1:A10", descending: true }])
322
+ #
323
+ # @param ref [String] The range to sort.
324
+ # @param sort_conditions [Array<Hash>, Hash] Sort conditions array or options hash.
325
+ # @param opts [Hash] Additional sort options.
326
+ # @return [void]
327
+ # @api public
328
+ #: (String ref, Array[Hash[Symbol, untyped]] | Hash[Symbol, untyped] sort_conditions, **untyped opts) -> void
329
+ def sort_state(...)
330
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
331
+
332
+ @writer.sort_state(...)
333
+ end
334
+
335
+ # Adds data validation rules to a range.
336
+ #
337
+ # @example Dropdown list validation
338
+ # s.validate_data("B2:B100", type: "list", formula1: '"High,Medium,Low"')
339
+ #
340
+ # @param range [String] The cell range (e.g. "B2:B10").
341
+ # @param type [String, Symbol] Validation type ("list", "whole", "decimal", "date", "time", "textLength", "custom").
342
+ # @param opts [Hash] Validation options.
343
+ # @return [void]
344
+ # @api public
345
+ #: (String range, ?type: String | Symbol, **untyped opts) -> void
346
+ def validate_data(...)
347
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
348
+
349
+ @writer.validate_data(...)
350
+ end
351
+
352
+ # Adds conditional formatting to a range.
353
+ #
354
+ # @example Highlight values greater than 100
355
+ # s.conditional_format("A1:A10", type: "cellIs", operator: "greaterThan", formula: 100, style: :highlight)
356
+ #
357
+ # @param range [String] The cell range (e.g. "A1:A10").
358
+ # @param type [String, Symbol] Rule type ("cellIs", "colorScale", "dataBar", "expression").
359
+ # @param opts [Hash] Rule options.
360
+ # @return [void]
361
+ # @api public
362
+ #: (String range, ?type: String | Symbol, **untyped opts) -> void
363
+ def conditional_format(...)
364
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
365
+
366
+ @writer.conditional_format(...)
367
+ end
368
+
369
+ # Adds a formatted Excel Table to the sheet.
370
+ #
371
+ # @example
372
+ # s.table("A1:C10", columns: ["ID", "Name", "Total"], name: "SalesTable", style: "TableStyleMedium9")
373
+ #
374
+ # @param ref [String] The cell range for the table (e.g. "A1:D10").
375
+ # @param columns [Array<String>, Array<Hash>] Column names or definitions.
376
+ # @param name [String, nil] Table name.
377
+ # @param display_name [String, nil] Display name.
378
+ # @param style [String, nil] Table style name.
379
+ # @param opts [Hash] Additional options.
380
+ # @return [void]
381
+ # @api public
382
+ #: (String ref, columns: untyped, ?name: String?, ?display_name: String?, ?style: String?, **untyped opts) -> void
383
+ def table(...)
384
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
385
+
386
+ @writer.table(...)
387
+ end
388
+
389
+ # simplecov:disable
390
+ # Edge case / untested delegation block
391
+ #: () -> void
392
+ def cleanup!(...)
393
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
394
+
395
+ @writer.cleanup!(...)
396
+ end
397
+ # simplecov:enable
398
+
399
+ # Adds a comment to a cell.
400
+ #
401
+ # @example
402
+ # s.comment("A1", "Reviewed and approved", author: "Auditor")
403
+ #
404
+ # @param cell [String, Integer] The cell reference (e.g. "A1").
405
+ # @param text [String] The comment text.
406
+ # @param author [String] The author name.
407
+ # @return [void]
408
+ # @api public
409
+ #: (String | Integer cell, String text, ?author: String) -> void
410
+ def comment(...)
411
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
412
+
413
+ @writer.comment(...)
414
+ end
415
+
416
+ # Adds a Pivot Table to the sheet.
417
+ #
418
+ # @param source_ref [String] Source data range reference (e.g. "Sheet1!A1:D100").
419
+ # @param row_fields [Array<String>] Field names for rows.
420
+ # @param data_fields [Array<String>] Field names for data values.
421
+ # @param col_fields [Array<String>] Field names for columns.
422
+ # @param dest_ref [String] Target top-left cell reference (default: "E1").
423
+ # @param name [String, nil] Pivot table name.
424
+ # @param field_names [Array<String>, nil] Override field names.
425
+ # @param items [Array, nil] Items configuration.
426
+ # @param opts [Hash] Additional options.
427
+ # @return [void]
428
+ # @api public
429
+ #: (String source_ref, row_fields: untyped, data_fields: untyped, ?col_fields: untyped, ?dest_ref: String, ?name: String?, ?field_names: untyped, ?items: untyped, **untyped opts) -> void
430
+ def pivot_table(...)
431
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
432
+
433
+ @writer.pivot_table(...)
434
+ end
435
+
436
+ # Adds sparklines to the sheet.
437
+ #
438
+ # @example
439
+ # s.sparkline_group(sparklines: [{ data_ref: "A1:E1", location_ref: "F1" }], type: "line")
440
+ #
441
+ # @param sparklines [Array<Hash>] Array of { data_ref:, location_ref: } hashes.
442
+ # @param type [String, Symbol, nil] "line" (default), "column", or "stacked".
443
+ # @param opts [Hash] Additional sparkline options.
444
+ # @return [void]
445
+ # @api public
446
+ #: (sparklines: Array[Hash[Symbol, untyped]], ?type: (String | Symbol)?, **untyped opts) -> void
447
+ def sparkline_group(...)
448
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
449
+
450
+ @writer.sparkline_group(...)
451
+ end
452
+
453
+ # Sets workbook-level properties.
454
+ #
455
+ # @param opts [Hash] Workbook property options.
456
+ # @return [void]
457
+ # @api public
458
+ #: (**untyped opts) -> void
459
+ def workbook_property(...)
460
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
461
+
462
+ @writer.workbook_property(...)
463
+ end
464
+
465
+ # Sets sheet properties (e.g. tab color, page setup flags).
466
+ #
467
+ # @example
468
+ # s.sheet_properties(:tab_color, "FF0000")
469
+ #
470
+ # @param name [Symbol, String] Property name.
471
+ # @param value [Object] Property value.
472
+ # @return [void]
473
+ # @api public
474
+ #: (Symbol | String name, untyped value) -> void
475
+ def sheet_properties(...)
476
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
477
+
478
+ @writer.sheet_properties(...)
479
+ end
480
+
481
+ # Adds a defined named range or formula.
482
+ #
483
+ # @example
484
+ # s.defined_name("TaxRate", "0.10")
485
+ #
486
+ # @param name [String] The name.
487
+ # @param formula [String] The formula or range expression.
488
+ # @param sheet_id [Integer, nil] Optional sheet scope.
489
+ # @param hidden [Boolean] Whether the name is hidden.
490
+ # @return [void]
491
+ # @api public
492
+ #: (String name, String formula, ?sheet_id: Integer | nil, ?hidden: bool) -> void
493
+ def defined_name(...)
494
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
495
+
496
+ @writer.defined_name(...)
497
+ end
498
+
499
+ # Freezes rows and/or columns for scrolling.
500
+ #
501
+ # @example Freeze top row
502
+ # s.freeze_pane(row: 1)
503
+ #
504
+ # @example Freeze first column and top 2 rows
505
+ # s.freeze_pane(row: 2, col: 1)
506
+ #
507
+ # @param row [Integer, nil] Number of rows to freeze.
508
+ # @param col [Integer, nil] Number of columns to freeze.
509
+ # @return [void]
510
+ # @api public
511
+ #: (?row: Integer | nil, ?col: Integer | nil) -> void
512
+ def freeze_pane(...)
513
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
514
+
515
+ @writer.freeze_pane(...)
516
+ end
517
+
518
+ # simplecov:disable
519
+ # Edge case / untested delegation block
520
+ # Sets the print area range for the sheet.
521
+ #
522
+ # @param ref [String] Range reference.
523
+ # @return [void]
524
+ # @api public
525
+ #: (String ref) -> void
526
+ def print_area(...)
527
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
528
+
529
+ @writer.print_area(...)
530
+ end
531
+ # simplecov:enable
532
+
533
+ # simplecov:disable
534
+ # Edge case / untested delegation block
535
+ # Configures repeating title rows and columns for printing.
536
+ #
537
+ # @param rows [String, nil] Row range to repeat (e.g. "1:2").
538
+ # @param cols [String, nil] Column range to repeat (e.g. "A:B").
539
+ # @return [void]
540
+ # @api public
541
+ #: (?rows: String?, ?cols: String?) -> void
542
+ def print_titles(...)
543
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
544
+
545
+ @writer.print_titles(...)
546
+ end
547
+ # simplecov:enable
548
+
549
+ # Splits sheet view into panes.
550
+ #
551
+ # @param x_split [Numeric, nil] Horizontal split position.
552
+ # @param y_split [Numeric, nil] Vertical split position.
553
+ # @param top_left_cell [String, nil] Top-left visible cell in bottom-right pane.
554
+ # @param active_pane [String, nil] Active pane identifier.
555
+ # @param state [String, nil] Split state.
556
+ # @return [void]
557
+ # @api public
558
+ #: (?x_split: Numeric | nil, ?y_split: Numeric | nil, ?top_left_cell: String?, ?active_pane: String?, ?state: String?) -> void
559
+ def split_pane(...)
560
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
561
+
562
+ @writer.split_pane(...)
563
+ end
564
+
565
+ # simplecov:disable
566
+ # Edge case / untested delegation block
567
+ # Protects the workbook structure.
568
+ #
569
+ # @param opts [Hash] Protection options.
570
+ # @return [void]
571
+ # @api public
572
+ #: (**untyped opts) -> void
573
+ def protect_workbook(...)
574
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
575
+
576
+ @writer.protect_workbook(...)
577
+ end
578
+ # simplecov:enable
579
+
580
+ # simplecov:disable
581
+ # Edge case / untested delegation block
582
+ # Sets core metadata property.
583
+ #
584
+ # @param name [String, Symbol] Property name.
585
+ # @param value [Object] Property value.
586
+ # @return [void]
587
+ # @api public
588
+ #: (String | Symbol name, untyped value) -> void
589
+ def core_property(...)
590
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
591
+
592
+ @writer.core_property(...)
593
+ end
594
+ # simplecov:enable
595
+
596
+ # Sets the active/selected cell on the sheet.
597
+ #
598
+ # @param active_cell [String] Cell reference (e.g. "A1").
599
+ # @param sqref [String, nil] Selection range.
600
+ # @param pane [String, Symbol, nil] Pane identifier.
601
+ # @return [void]
602
+ # @api public
603
+ #: (String active_cell, ?sqref: String?, ?pane: (String | Symbol)?) -> void
604
+ def select_cell(...)
605
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
606
+
607
+ @writer.select_cell(...)
608
+ end
609
+
610
+ # Configures page margins for printing.
611
+ #
612
+ # @param left [Float, nil] Left margin in inches.
613
+ # @param right [Float, nil] Right margin in inches.
614
+ # @param top [Float, nil] Top margin in inches.
615
+ # @param bottom [Float, nil] Bottom margin in inches.
616
+ # @param header [Float, nil] Header margin in inches.
617
+ # @param footer [Float, nil] Footer margin in inches.
618
+ # @return [void]
619
+ # @api public
620
+ #: (?left: Float | nil, ?right: Float | nil, ?top: Float | nil, ?bottom: Float | nil, ?header: Float | nil, ?footer: Float | nil) -> void
621
+ def page_margins(...)
622
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
623
+
624
+ @writer.page_margins(...)
625
+ end
626
+
627
+ # Configures page orientation, paper size, and print setup.
628
+ #
629
+ # @param orientation [String, Symbol, nil] "portrait" or "landscape" (or :portrait, :landscape).
630
+ # @param paper_size [Integer, nil] Paper size index (e.g. 9 for A4, 1 for Letter).
631
+ # @param opts [Hash] Additional options (scale, fit_to_width, fit_to_height).
632
+ # @return [void]
633
+ # @api public
634
+ #: (?orientation: (String | Symbol)?, ?paper_size: Integer | nil, **untyped opts) -> void
635
+ def page_setup(...)
636
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
637
+
638
+ @writer.page_setup(...)
639
+ end
640
+
641
+ # Configures header and footer text for printing.
642
+ #
643
+ # @param opts [Hash] Header and footer specifications.
644
+ # @return [void]
645
+ # @api public
646
+ #: (**untyped opts) -> void
647
+ def header_footer(...)
648
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
649
+
650
+ @writer.header_footer(...)
651
+ end
652
+
653
+ # Configures print options (e.g. gridlines, headings).
654
+ #
655
+ # @param name [Symbol, String] Print option name.
656
+ # @param value [Object] Print option value.
657
+ # @return [void]
658
+ # @api public
659
+ #: (Symbol | String name, untyped value) -> void
660
+ def print_options(...)
661
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
662
+
663
+ @writer.print_options(...)
664
+ end
665
+
666
+ # simplecov:disable
667
+ # Edge case / untested delegation block
668
+ # Sets document metadata properties (core, app, custom).
669
+ #
670
+ # @param core [Hash, nil] Core properties (title, creator, subject, etc.).
671
+ # @param app [Hash, nil] App properties (company, manager).
672
+ # @param custom [Hash, nil] Custom properties.
673
+ # @return [void]
674
+ # @api public
675
+ #: (?core: Hash[untyped, untyped]?, ?app: Hash[untyped, untyped]?, ?custom: Hash[untyped, untyped]?) -> void
676
+ def properties(...)
677
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
678
+
679
+ @writer.properties(...)
680
+ end
681
+ # simplecov:enable
682
+
683
+ # simplecov:disable
684
+ # Edge case / untested delegation block
685
+ # Sets app metadata property.
686
+ #
687
+ # @param name [String, Symbol] Property name.
688
+ # @param value [Object] Property value.
689
+ # @return [void]
690
+ # @api public
691
+ #: (String | Symbol name, untyped value) -> void
692
+ def app_property(...)
693
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
694
+
695
+ @writer.app_property(...)
696
+ end
697
+ # simplecov:enable
698
+
699
+ # Protects the worksheet against modifications.
700
+ #
701
+ # @param opts [Hash] Protection options.
702
+ # @return [void]
703
+ # @api public
704
+ #: (**untyped opts) -> void
705
+ def protect_sheet(...)
706
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
707
+
708
+ @writer.protect_sheet(...)
709
+ end
710
+
711
+ # simplecov:disable
712
+ # Edge case / untested delegation block
713
+ # Sets custom metadata property.
714
+ #
715
+ # @param name [String, Symbol] Property name.
716
+ # @param value [Object] Property value.
717
+ # @return [void]
718
+ # @api public
719
+ #: (String | Symbol name, untyped value) -> void
720
+ def custom_property(...)
721
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
722
+
723
+ @writer.custom_property(...)
724
+ end
725
+ # simplecov:enable
726
+
727
+ # Inserts an image into the sheet.
728
+ #
729
+ # @param file_data [String] Binary image data or file content.
730
+ # @param ext [String] Image extension ("png", "jpeg", etc.).
731
+ # @param from_col [Integer] Starting column index (0-based).
732
+ # @param from_row [Integer] Starting row index (0-based).
733
+ # @param to_col [Integer] Ending column index (0-based).
734
+ # @param to_row [Integer] Ending row index (0-based).
735
+ # @param opts [Hash] Additional anchor and sizing options.
736
+ # @return [void]
737
+ # @api public
738
+ #: (String file_data, ?ext: String, ?from_col: Integer, ?from_row: Integer, ?to_col: Integer, ?to_row: Integer, **untyped opts) -> void
739
+ def image(...)
740
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
741
+
742
+ @writer.image(...)
743
+ end
744
+
745
+ # Configures sheet view settings (zoom scale, grid lines visibility).
746
+ #
747
+ # @param name [Symbol, String] View setting name.
748
+ # @param value [Object] View setting value.
749
+ # @return [void]
750
+ # @api public
751
+ #: (Symbol | String name, untyped value) -> void
752
+ def sheet_view(...)
753
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
754
+
755
+ @writer.sheet_view(...)
756
+ end
757
+
758
+ # simplecov:disable
759
+ # Edge case / untested delegation block
760
+ # Adds a horizontal page break after the given row index.
761
+ #
762
+ # @param row_index [Integer] 0-based row index.
763
+ # @return [void]
764
+ # @api public
765
+ #: (Integer row_index) -> void
766
+ def page_break_row(...)
767
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
768
+
769
+ @writer.page_break_row(...)
770
+ end
771
+ # simplecov:enable
772
+
773
+ # simplecov:disable
774
+ # Edge case / untested delegation block
775
+ # Adds a vertical page break after the given column index.
776
+ #
777
+ # @param col_index [Integer] 0-based column index.
778
+ # @return [void]
779
+ # @api public
780
+ #: (Integer col_index) -> void
781
+ def page_break_col(...)
782
+ raise Error, "Sheet '#{@sheet_name}' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
783
+
784
+ @writer.page_break_col(...)
785
+ end
786
+ # simplecov:enable
787
+ end
788
+
789
+ # Adds a new sheet to the workbook and starts streaming rows into it.
790
+ #
791
+ # @param name [String, nil] Sheet name (max 31 characters).
792
+ # @param opts [Hash] Sheet-level configuration.
793
+ # @yield [sheet_proxy]
794
+ # @yieldparam sheet_proxy [WorksheetProxy] The streaming sheet proxy.
795
+ # @return [String] The sheet name.
796
+ # @api public
797
+ #: (?String? name, **untyped opts) ?{ (WorksheetProxy) -> void } -> untyped
798
+ def sheet(name = nil, **opts)
799
+ name ||= "Sheet#{@sheets.size + 1}"
800
+ raise ArgumentError, "Sheet name '#{name}' must be <= 31 characters (Excel limitation)" if @strict_excel_mode && name.length > 31
801
+ raise ArgumentError, "Sheet name '#{name}' contains invalid characters (ECMA-376 OOXML specification)" if name.match?(%r{[\[\]*?/\\]})
802
+ raise ArgumentError, "Sheet name '#{name}' is already used. Excel requires unique sheet names." if @strict_excel_mode && @sheets.map { |s| s.respond_to?(:name) ? s.name.downcase : s.to_s.downcase }.include?(name.downcase)
803
+
804
+ internal_sheet_setup(name)
805
+ opts.each { |k, v| sheet_properties(k, v) }
806
+
807
+ yield WorksheetProxy.new(self, @current_sheet) if block_given?
808
+ @current_sheet
809
+ end
810
+
811
+ # Internal: Start or switch to a named sheet (internal helper).
812
+ #: (?String? name) ?{ (WorksheetProxy) -> void } -> (WorksheetProxy | nil)
813
+ def internal_sheet_setup(name = nil)
814
+ flush_current_sheet
815
+ name ||= "Sheet#{@sheets.size + 1}"
816
+ @current_sheet = name
817
+ @current_row_index = 0
818
+ @current_tempfile = Tempfile.new(["xlsxrb_rows", ".xml"])
819
+ @current_tempfile.binmode
820
+ @current_row_writer = Ooxml::WorksheetWriter.new(@current_tempfile)
821
+ @current_row_writer.instance_variable_set(:@started, true)
822
+
823
+ @current_columns = []
824
+ @current_charts = []
825
+ @current_hyperlinks = []
826
+ @current_auto_filter = nil
827
+ @current_filter_columns = {}
828
+ @current_sort_state = nil
829
+ @current_data_validations = []
830
+ @current_conditional_formats = []
831
+ @current_tables = []
832
+ @current_pivot_tables = []
833
+ @current_sparkline_groups = []
834
+ @current_comments = []
835
+ @current_merge_cells = []
836
+ @current_freeze_pane = nil
837
+ @current_split_pane = nil
838
+ @current_selection = nil
839
+ @current_page_margins = nil
840
+ @current_page_setup = {}
841
+ @current_header_footer = {}
842
+ @current_print_options = {}
843
+ @current_sheet_protection = nil
844
+ @current_images = []
845
+ @current_shapes = []
846
+ @current_sheet_properties = {}
847
+ @current_sheet_view = {}
848
+ @current_row_breaks = []
849
+ @current_col_breaks = []
850
+ @current_cells = {}
851
+
852
+ return unless block_given?
853
+
854
+ # simplecov:disable
855
+ # Edge case / untested delegation block
856
+ yield self
857
+ flush_current_sheet
858
+ # simplecov:enable
859
+ end
860
+
861
+ # Appends a row of values to the active sheet.
862
+ #
863
+ # @param values [Array<Object>, Hash] The cell values.
864
+ # @param styles [String, Symbol, Array, Hash, nil] Style names or inline style definitions.
865
+ # @param height [Float, Integer, nil] The row height in points (0 - 409).
866
+ # @param hidden [Boolean] Whether the row is hidden.
867
+ # @param custom_height [Boolean] Whether custom row height is set.
868
+ # @param outline_level [Integer, nil] Grouping/outline level (0 - 7).
869
+ # @return [void]
870
+ # @api public
871
+ #: (Array[untyped] | Hash[untyped, untyped] values, ?styles: untyped, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil) -> void
872
+ def row(values, styles: nil, height: nil, hidden: false, custom_height: false, outline_level: nil)
873
+ sheet if @current_sheet.nil?
874
+
875
+ row_index = @current_row_index
876
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
877
+ if @strict_excel_mode
878
+ raise ArgumentError, "Row index #{row_index} exceeds Excel limit of 1,048,576 rows" if row_index >= 1_048_576
879
+ raise ArgumentError, "Row height #{height} must be between 0 and 409 points (Excel limitation)" if height && (height.negative? || height > 409)
880
+ end
881
+ @current_row_index += 1
882
+
883
+ if values.is_a?(Hash)
884
+ max_col = values.keys.map { |k| Elements::Cell.column_index(k) }.max || -1
885
+ cells_array = Array.new(max_col + 1)
886
+ values.each do |k, v|
887
+ idx = Elements::Cell.column_index(k)
888
+ cells_array[idx] = v
889
+ end
890
+ values = cells_array
891
+ end
892
+
893
+ if styles.is_a?(Hash)
894
+ expanded_styles = {}
895
+ styles.each do |k, v|
896
+ if k.is_a?(Range) || k.is_a?(Array)
897
+ k.each { |idx| expanded_styles[Elements::Cell.column_index(idx)] = v }
898
+ else
899
+ expanded_styles[Elements::Cell.column_index(k)] = v
900
+ end
901
+ end
902
+ max_col_style = expanded_styles.keys.max || -1
903
+ styles_array = Array.new(max_col_style + 1)
904
+ expanded_styles.each do |idx, v|
905
+ styles_array[idx] = v
906
+ end
907
+ styles = styles_array
908
+ end
909
+
910
+ # Auto-detect Date / Time for built-in styles
911
+ values.each_with_index do |val, idx|
912
+ cell_style = styles.is_a?(Array) ? styles[idx] : styles
913
+
914
+ if val.is_a?(Date) && cell_style.nil?
915
+ style("__xlsxrb_date", number_format: "yyyy-mm-dd") unless @styles.key?("__xlsxrb_date")
916
+ styles = [] if styles.nil?
917
+ styles = Array.new(values.size, styles) unless styles.is_a?(Array)
918
+ styles[idx] = "__xlsxrb_date"
919
+ elsif val.is_a?(Time) && cell_style.nil?
920
+ style("__xlsxrb_time", number_format: "yyyy-mm-dd hh:mm:ss") unless @styles.key?("__xlsxrb_time")
921
+ styles = [] if styles.nil?
922
+ styles = Array.new(values.size, styles) unless styles.is_a?(Array)
923
+ styles[idx] = "__xlsxrb_time"
924
+ end
925
+ end
926
+
927
+ has_charts = @current_charts && !@current_charts.empty?
928
+ row_num = row_index + 1 if has_charts
929
+
930
+ max_len = values.size
931
+ max_len = [max_len, styles.size].max if styles.is_a?(Array)
932
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
933
+ raise ArgumentError, "Row contains #{max_len} columns, exceeding Excel limit of 16_384 columns" if @strict_excel_mode && max_len > 16_384
934
+
935
+ if has_charts || @strict_excel_mode
936
+ @current_cells ||= {} if has_charts
937
+ max_len.times do |col_idx|
938
+ val = col_idx < values.size ? values[col_idx] : nil
939
+ next if val.nil?
940
+
941
+ raise ArgumentError, "Invalid cell value type or value: #{val.class} for value #{val.inspect}" unless val.nil? || val.is_a?(String) || (val.is_a?(Numeric) && !(val.is_a?(Float) && (val.infinite? || val.nan?))) || val.is_a?(TrueClass) || val.is_a?(FalseClass) || val.is_a?(Date) || val.is_a?(Time) || val.is_a?(Elements::Formula) || (val.is_a?(Hash) && val.key?(:formula)) || val.is_a?(Elements::RichText) || val.is_a?(Elements::CellError)
942
+
943
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
944
+ raise ArgumentError, "Cell text length #{val.length} exceeds Excel limit of 32,767 characters" if @strict_excel_mode && val.is_a?(String) && val.length > 32_767
945
+
946
+ if has_charts
947
+ addr = "#{Elements::Cell.column_letter(col_idx)}#{row_num}"
948
+ @current_cells[addr] = val
949
+ end
950
+ end
951
+ end
952
+
953
+ attrs = nil
954
+ if height || hidden || outline_level
955
+ attrs = {}
956
+ attrs[:height] = height if height
957
+ attrs[:hidden] = true if hidden
958
+ attrs[:custom_height] = custom_height || !height.nil?
959
+ attrs[:outline_level] = outline_level if outline_level
960
+ end
961
+
962
+ @current_row_writer.write_row_values(row_index, values, styles: styles, style_map: @style_name_to_id, sst: @sst, sst_index: @sst_index, attrs: attrs)
963
+ end
964
+
965
+ # Sets column formatting and properties for one or multiple columns.
966
+ #
967
+ # @param index [Integer, String, Range, Array] Column index (0-based) or letter ("A".."D").
968
+ # @param width [Float, Integer, nil] Column width in character units (0 - 255).
969
+ # @param hidden [Boolean] Whether the column is hidden.
970
+ # @param custom_width [Boolean] Whether custom width is set.
971
+ # @param outline_level [Integer, nil] Grouping/outline level.
972
+ # @return [void]
973
+ # @api public
974
+ #: (Integer | String | Range[Integer | String] | Array[Integer | String] index, ?width: Float | Integer | nil, ?hidden: bool, ?custom_width: bool, ?outline_level: Integer | nil) -> void
975
+ def column(index, width: nil, hidden: false, custom_width: false, outline_level: nil)
976
+ raise ArgumentError, "Column width #{width} must be between 0 and 255 characters (Excel limitation)" if @strict_excel_mode && width && (width.negative? || width > 255)
977
+
978
+ indices = case index
979
+ when Range, Array
980
+ index.map { |i| Elements::Cell.column_index(i) }
981
+ else
982
+ [Elements::Cell.column_index(index)]
983
+ end
984
+
985
+ sheet if @current_sheet.nil?
986
+
987
+ indices.each do |idx|
988
+ @current_columns << { index: idx, width: width, hidden: hidden, custom_width: custom_width || !width.nil?, outline_level: outline_level }
989
+ end
990
+ end
991
+
992
+ # Adds a chart to the current worksheet.
993
+ #
994
+ # @param options [Hash] Chart options.
995
+ # @yield [builder]
996
+ # @yieldparam builder [Xlsxrb::ChartBuilder]
997
+ # @return [void]
998
+ # @api public
999
+ #: (**untyped options) ?{ (ChartBuilder) -> void } -> void
1000
+ def chart(**options)
1001
+ sheet if @current_sheet.nil?
1002
+
1003
+ if block_given?
1004
+ builder = ChartBuilder.new
1005
+ yield builder
1006
+ options = builder.options.merge(options)
1007
+ end
1008
+
1009
+ @current_charts << options
1010
+ end
1011
+
1012
+ # Adds a hyperlink on a cell.
1013
+ #
1014
+ # @param cell [String, Integer] Cell coordinate (e.g. "A1").
1015
+ # @param url [String, nil] Target URL.
1016
+ # @param display [String, nil] Display text.
1017
+ # @param tooltip [String, nil] Tooltip text.
1018
+ # @param location [String, nil] Internal location.
1019
+ # @return [void]
1020
+ # @api public
1021
+ #: (String | Integer cell, ?String? url, ?display: String?, ?tooltip: String?, ?location: String?) -> void
1022
+ def hyperlink(cell, url = nil, display: nil, tooltip: nil, location: nil)
1023
+ sheet if @current_sheet.nil?
1024
+ link = { cell: cell }
1025
+ link[:url] = url if url
1026
+ link[:display] = display if display
1027
+ link[:tooltip] = tooltip if tooltip
1028
+ link[:location] = location if location
1029
+ @current_hyperlinks << link
1030
+ end
1031
+
1032
+ # Sets the auto-filter range on the active sheet.
1033
+ #
1034
+ # @param range [String] Cell range (e.g. "A1:E100").
1035
+ # @return [void]
1036
+ # @api public
1037
+ #: (String range) -> void
1038
+ def auto_filter(range)
1039
+ sheet if @current_sheet.nil?
1040
+ @current_auto_filter = range
1041
+ end
1042
+
1043
+ # Sets filter criteria on a column in the auto-filter.
1044
+ #
1045
+ # @param col_id [Integer] 0-based column index.
1046
+ # @param filter [Hash] Filter criteria.
1047
+ # @return [void]
1048
+ # @api public
1049
+ #: (untyped col_id, untyped filter) -> untyped
1050
+ def filter_column(col_id, filter)
1051
+ sheet if @current_sheet.nil?
1052
+ @current_filter_columns[col_id] = filter
1053
+ end
1054
+
1055
+ # Configures column sort state on the active sheet.
1056
+ #
1057
+ # @param ref [String] The sorted range.
1058
+ # @param sort_conditions [Array<Hash>] Sort conditions.
1059
+ # @param opts [Hash] Additional options.
1060
+ # @return [void]
1061
+ # @api public
1062
+ #: (untyped ref, untyped sort_conditions, **untyped opts) -> untyped
1063
+ def sort_state(ref, sort_conditions, **opts)
1064
+ sheet if @current_sheet.nil?
1065
+ @current_sort_state = { ref: ref, sort_conditions: sort_conditions }.merge(opts)
1066
+ end
1067
+
1068
+ # Adds a data validation rule.
1069
+ #
1070
+ # @param sqref [String] Cell range.
1071
+ # @param opts [Hash] Validation options.
1072
+ # @return [void]
1073
+ # @api public
1074
+ #: (untyped sqref, **untyped opts) -> void
1075
+ def validate_data(sqref, **opts)
1076
+ sheet if @current_sheet.nil?
1077
+ @current_data_validations << opts.merge(sqref: sqref)
1078
+ end
1079
+
1080
+ # Adds a conditional formatting rule.
1081
+ #
1082
+ # @param sqref [String] Cell range.
1083
+ # @param opts [Hash] Rule options.
1084
+ # @return [void]
1085
+ # @api public
1086
+ #: (untyped sqref, **untyped opts) -> void
1087
+ def conditional_format(sqref, **opts)
1088
+ sheet if @current_sheet.nil?
1089
+ @current_conditional_formats << opts.merge(sqref: sqref)
1090
+ end
1091
+
1092
+ # Adds an Excel Table (ListObject).
1093
+ #
1094
+ # @param ref [String] Table range.
1095
+ # @param columns [Array<String>] Column names.
1096
+ # @param name [String, nil] Table name.
1097
+ # @param display_name [String, nil] Display name.
1098
+ # @param style [String, nil] Table style.
1099
+ # @param opts [Hash] Additional options.
1100
+ # @return [void]
1101
+ # @api public
1102
+ #: (untyped ref, columns: untyped, ?name: untyped, ?display_name: untyped, ?style: untyped, **untyped opts) -> void
1103
+ def table(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
1104
+ sheet if @current_sheet.nil?
1105
+ tbl = { ref: ref, columns: columns }
1106
+ tbl[:name] = name if name
1107
+ tbl[:display_name] = display_name if display_name
1108
+ tbl[:style] = style if style
1109
+ tbl.merge!(opts)
1110
+ @current_tables << tbl
1111
+ end
1112
+
1113
+ # Adds a Pivot Table.
1114
+ #
1115
+ # @param source_ref [String] Source data range.
1116
+ # @param row_fields [Array<Integer>] 0-based field indices for rows.
1117
+ # @param data_fields [Array<Hash>] Data fields.
1118
+ # @param col_fields [Array<Integer>] 0-based field indices for columns.
1119
+ # @param dest_ref [String] Top-left destination cell (default: "E1").
1120
+ # @param name [String, nil] Pivot table name.
1121
+ # @param field_names [Array<String>, nil] Override field names.
1122
+ # @param items [Array, nil] Items configuration.
1123
+ # @param opts [Hash] Additional options.
1124
+ # @return [void]
1125
+ # @api public
1126
+ #: (untyped source_ref, row_fields: untyped, data_fields: untyped, ?col_fields: untyped, ?dest_ref: untyped, ?name: untyped, ?field_names: untyped, ?items: untyped, **untyped opts) -> void
1127
+ def pivot_table(source_ref, row_fields:, data_fields:, col_fields: [], dest_ref: "E1", name: nil, field_names: nil, items: nil)
1128
+ sheet if @current_sheet.nil?
1129
+ @current_pivot_tables ||= []
1130
+ @current_pivot_tables << {
1131
+ source_ref: source_ref, row_fields: row_fields,
1132
+ data_fields: data_fields, col_fields: col_fields,
1133
+ dest_ref: dest_ref, name: name,
1134
+ field_names: field_names, items: items
1135
+ }
1136
+ end
1137
+
1138
+ # Adds a comment to a cell.
1139
+ #
1140
+ # @param cell [String, Integer] Cell coordinate (e.g. "A1").
1141
+ # @param text [String] Comment text.
1142
+ # @param author [String] Author name.
1143
+ # @return [void]
1144
+ # @api public
1145
+ #: (String | Integer cell, String text, ?author: ::String) -> void
1146
+ def comment(cell, text, author: "Author")
1147
+ sheet if @current_sheet.nil?
1148
+ @current_comments << { cell: cell, text: text, author: author }
1149
+ end
1150
+
1151
+ # Adds a sparkline group.
1152
+ #
1153
+ # @param sparklines [Array<Hash>] Sparkline definitions.
1154
+ # @param type [String, nil] "line", "column", or "stacked".
1155
+ # @param opts [Hash] Additional options.
1156
+ # @return [void]
1157
+ # @api public
1158
+ #: (sparklines: untyped, ?type: untyped, **untyped opts) -> void
1159
+ def sparkline_group(sparklines:, type: nil, **opts)
1160
+ sheet if @current_sheet.nil?
1161
+ group = { sparklines: sparklines }
1162
+ group[:type] = type if type
1163
+ group.merge!(opts)
1164
+ @current_sparkline_groups << group
1165
+ end
1166
+
1167
+ # Merges a range of cells into a single cell.
1168
+ #
1169
+ # @param range [String, Hash, nil] Cell range (e.g. "A1:B2") or hash of coordinates.
1170
+ # @param row [Integer, nil] Single row index.
1171
+ # @param col_start [Integer, String, nil] Starting column.
1172
+ # @param col_end [Integer, String, nil] Ending column.
1173
+ # @param row_start [Integer, nil] Starting row index.
1174
+ # @param row_end [Integer, nil] Ending row index.
1175
+ # @return [void]
1176
+ # @api public
1177
+ #: (?(String | Hash[Symbol, Integer | String])? range, ?row: Integer?, ?col_start: (Integer | String)?, ?col_end: (Integer | String)?, ?row_start: Integer?, ?row_end: Integer?) -> void
1178
+ def merge(range = nil, row: nil, col_start: nil, col_end: nil, row_start: nil, row_end: nil)
1179
+ sheet if @current_sheet.nil?
1180
+ if range.is_a?(Hash)
1181
+ row = range[:row]
1182
+ row_start = range[:row_start]
1183
+ row_end = range[:row_end]
1184
+ col_start = range[:col_start]
1185
+ col_end = range[:col_end]
1186
+ range = nil
1187
+ end
1188
+
1189
+ if range
1190
+ raise ArgumentError, "Invalid merge range format: '#{range}'. Expected format like 'A1:B2'." if @strict_excel_mode && !range.match?(/^[A-Za-z]{1,3}\d+(:[A-Za-z]{1,3}\d+)?$/)
1191
+
1192
+ @current_merge_cells << range
1193
+ else
1194
+ r_start = row || row_start || 0
1195
+ r_end = row || row_end || 0
1196
+ c_start = Elements::Cell.column_index(col_start || 0)
1197
+ c_end = Elements::Cell.column_index(col_end || 0)
1198
+ start_ref = "#{Xlsxrb::Elements::Cell.column_letter(c_start)}#{r_start + 1}"
1199
+ end_ref = "#{Xlsxrb::Elements::Cell.column_letter(c_end)}#{r_end + 1}"
1200
+ @current_merge_cells << "#{start_ref}:#{end_ref}"
1201
+ end
1202
+ end
1203
+
1204
+ # Freezes window panes at the given row and column.
1205
+ #
1206
+ # @param row [Integer] Number of rows to freeze.
1207
+ # @param col [Integer, String] Number of columns to freeze.
1208
+ # @return [void]
1209
+ # @api public
1210
+ #: (?row: Integer, ?col: (Integer | String)) -> void
1211
+ def freeze_pane(row: 0, col: 0)
1212
+ col = Elements::Cell.column_index(col)
1213
+ sheet if @current_sheet.nil?
1214
+ @current_freeze_pane = { row: row, col: col }
1215
+ end
1216
+
1217
+ # Splits window panes without freezing.
1218
+ #
1219
+ # @param x_split [Integer] Horizontal split in points.
1220
+ # @param y_split [Integer] Vertical split in points.
1221
+ # @param top_left_cell [String, nil] Top-left cell reference in bottom-right pane.
1222
+ # @return [void]
1223
+ # @api public
1224
+ #: (?x_split: ::Integer, ?y_split: ::Integer, ?top_left_cell: String?) -> void
1225
+ def split_pane(x_split: 0, y_split: 0, top_left_cell: nil)
1226
+ sheet if @current_sheet.nil?
1227
+ @current_split_pane = { x_split: x_split, y_split: y_split, top_left_cell: top_left_cell }
1228
+ end
1229
+
1230
+ # Sets the active cell selection on the current sheet.
1231
+ #
1232
+ # @param active_cell [String] Active cell reference (e.g. "A1").
1233
+ # @param sqref [String, nil] Selection range.
1234
+ # @param pane [String, Symbol, nil] Target pane.
1235
+ # @return [void]
1236
+ # @api public
1237
+ #: (String active_cell, ?sqref: String?, ?pane: (String | Symbol)?) -> void
1238
+ def select_cell(active_cell, sqref: nil, pane: nil)
1239
+ sheet if @current_sheet.nil?
1240
+ @current_selection = { active_cell: active_cell, sqref: sqref || active_cell }
1241
+ @current_selection[:pane] = pane if pane
1242
+ end
1243
+
1244
+ # Sets page margins in inches for printing.
1245
+ #
1246
+ # @param left [Float, nil] Left margin.
1247
+ # @param right [Float, nil] Right margin.
1248
+ # @param top [Float, nil] Top margin.
1249
+ # @param bottom [Float, nil] Bottom margin.
1250
+ # @param header [Float, nil] Header margin.
1251
+ # @param footer [Float, nil] Footer margin.
1252
+ # @return [void]
1253
+ # @api public
1254
+ #: (?left: Float?, ?right: Float?, ?top: Float?, ?bottom: Float?, ?header: Float?, ?footer: Float?) -> void
1255
+ def page_margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
1256
+ sheet if @current_sheet.nil?
1257
+ @current_page_margins = { left: left, right: right, top: top, bottom: bottom, header: header, footer: footer }.compact
1258
+ end
1259
+
1260
+ # Sets page setup configuration.
1261
+ #
1262
+ # @param opts [Hash] Page setup options (e.g. orientation: :landscape).
1263
+ # @return [void]
1264
+ # @api public
1265
+ #: (**untyped opts) -> void
1266
+ def page_setup(**opts)
1267
+ sheet if @current_sheet.nil?
1268
+ @current_page_setup.merge!(opts)
1269
+ end
1270
+
1271
+ # Configures headers and footers for printing.
1272
+ #
1273
+ # @param opts [Hash] Header/footer options.
1274
+ # @return [void]
1275
+ # @api public
1276
+ #: (**untyped opts) -> void
1277
+ def header_footer(**opts)
1278
+ sheet if @current_sheet.nil?
1279
+ @current_header_footer.merge!(opts)
1280
+ end
1281
+
1282
+ # Sets a print option (e.g. grid_lines: true).
1283
+ #
1284
+ # @param name [Symbol] Option name.
1285
+ # @param value [Object] Option value.
1286
+ # @return [void]
1287
+ # @api public
1288
+ #: (Symbol name, untyped value) -> void
1289
+ def print_options(name, value)
1290
+ sheet if @current_sheet.nil?
1291
+ @current_print_options[name] = value
1292
+ end
1293
+
1294
+ # Sets sheet-level protection with optional password hashing.
1295
+ #
1296
+ # @param opts [Hash] Protection options.
1297
+ # @return [void]
1298
+ # @api public
1299
+ #: (**untyped opts) -> void
1300
+ def protect_sheet(**opts)
1301
+ sheet if @current_sheet.nil?
1302
+ normalized = opts.dup
1303
+ plain_password = normalized[:password]
1304
+ needs_hash = plain_password.is_a?(String) && !plain_password.empty? &&
1305
+ normalized[:algorithm_name].nil? && normalized[:hash_value].nil? &&
1306
+ normalized[:salt_value].nil? && normalized[:spin_count].nil? &&
1307
+ !plain_password.match?(/\A[0-9A-Fa-f]{4}\z/)
1308
+ if needs_hash
1309
+ normalized.delete(:password)
1310
+ normalized.merge!(Xlsxrb::Ooxml::Utils.hash_password(plain_password))
1311
+ end
1312
+ @current_sheet_protection = normalized
1313
+ end
1314
+
1315
+ # Inserts an embedded image into the active sheet.
1316
+ #
1317
+ # @param file_data [String] Binary image data.
1318
+ # @param ext [String] File extension ("png", "jpeg", etc.).
1319
+ # @param from_col [Integer] Top-left starting column.
1320
+ # @param from_row [Integer] Top-left starting row.
1321
+ # @param to_col [Integer] Bottom-right ending column.
1322
+ # @param to_row [Integer] Bottom-right ending row.
1323
+ # @param opts [Hash] Additional options.
1324
+ # @return [void]
1325
+ # @api public
1326
+ #: (String file_data, ?ext: ::String, ?from_col: ::Integer, ?from_row: ::Integer, ?to_col: ::Integer, ?to_row: ::Integer, **untyped opts) -> void
1327
+ def image(file_data, ext: "png", from_col: 0, from_row: 0, to_col: 5, to_row: 10, **opts)
1328
+ sheet if @current_sheet.nil?
1329
+ img = { file_data: file_data, ext: ext, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
1330
+ img.merge!(opts)
1331
+ @current_images << img
1332
+ end
1333
+
1334
+ # Inserts a drawing shape.
1335
+ #
1336
+ # @param preset [String] Preset shape name.
1337
+ # @param text [String, nil] Label text.
1338
+ # @param from_col [Integer] Top-left column.
1339
+ # @param from_row [Integer] Top-left row.
1340
+ # @param to_col [Integer] Bottom-right column.
1341
+ # @param to_row [Integer] Bottom-right row.
1342
+ # @param opts [Hash] Additional options.
1343
+ # @return [void]
1344
+ # @api public
1345
+ #: (**untyped opts) -> void
1346
+ def shape(preset: "rect", text: nil, from_col: 0, from_row: 0, to_col: 5, to_row: 5, **opts)
1347
+ sheet if @current_sheet.nil?
1348
+ shape = { preset: preset, text: text, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
1349
+ shape[:name] = opts.delete(:name) || "Shape #{@current_shapes.size + 1}"
1350
+ shape.merge!(opts)
1351
+ @current_shapes << shape
1352
+ end
1353
+
1354
+ # Sets a sheet-level property (e.g. tab_color: "FF0000").
1355
+ #
1356
+ # @param name [Symbol] Property name.
1357
+ # @param value [Object] Property value.
1358
+ # @return [void]
1359
+ # @api public
1360
+ #: (Symbol name, untyped value) -> void
1361
+ def sheet_properties(name, value)
1362
+ sheet if @current_sheet.nil?
1363
+ @current_sheet_properties[name] = value
1364
+ end
1365
+
1366
+ # Sets a sheet view property (e.g. zoom_scale: 120).
1367
+ #
1368
+ # @param name [Symbol] View property name.
1369
+ # @param value [Object] View property value.
1370
+ # @return [void]
1371
+ # @api public
1372
+ #: (Symbol name, untyped value) -> void
1373
+ def sheet_view(name, value)
1374
+ sheet if @current_sheet.nil?
1375
+ @current_sheet_view[name] = value
1376
+ end
1377
+
1378
+ # Inserts a horizontal page break before a row.
1379
+ #
1380
+ # @param row_num [Integer] 1-based row number.
1381
+ # @return [void]
1382
+ # @api public
1383
+ #: (Integer row_num) -> void
1384
+ def page_break_row(row_num)
1385
+ # simplecov:disable
1386
+ # Edge case / untested delegation block
1387
+ sheet if @current_sheet.nil?
1388
+ @current_row_breaks << row_num
1389
+ # simplecov:enable
1390
+ end
1391
+
1392
+ # Inserts a vertical page break before a column.
1393
+ #
1394
+ # @param col_index [Integer, String] 0-based column index or letter ("B").
1395
+ # @return [void]
1396
+ # @api public
1397
+ #: (Integer col_index) -> void
1398
+ def page_break_col(col_index)
1399
+ # simplecov:disable
1400
+ # Edge case / untested delegation block
1401
+ col_index = Elements::Cell.column_index(col_index)
1402
+ sheet if @current_sheet.nil?
1403
+ @current_col_breaks << col_index
1404
+ # simplecov:enable
1405
+ end
1406
+
1407
+ # --- Workbook-Level Methods ---
1408
+
1409
+ # Adds a defined name.
1410
+ #
1411
+ # @param name [String] The defined name.
1412
+ # @param value [String] The formula or value expression.
1413
+ # @param sheet [String, nil] Local sheet name.
1414
+ # @param hidden [Boolean] Whether the defined name is hidden.
1415
+ # @return [void]
1416
+ # @api public
1417
+ #: (String name, String value, ?sheet: String?, ?hidden: bool) -> void
1418
+ def defined_name(name, value, sheet: nil, hidden: false)
1419
+ entry = { name: name, value: value, hidden: hidden }
1420
+ if sheet
1421
+ # local_sheet_id will be resolved at close time
1422
+ entry[:local_sheet_name] = sheet
1423
+ end
1424
+ @defined_names << entry
1425
+ end
1426
+
1427
+ # Sets the print area for the current or named sheet.
1428
+ #
1429
+ # @param range [String] Cell range (e.g. "A1:G50").
1430
+ # @param sheet [String, nil] Target sheet name.
1431
+ # @return [void]
1432
+ # @api public
1433
+ #: (String range, ?sheet: String?) -> void
1434
+ def print_area(range, sheet: nil)
1435
+ # simplecov:disable
1436
+ # Edge case / untested delegation block
1437
+ sheet_name = sheet || @current_sheet || "Sheet1"
1438
+ value = "'#{sheet_name}'!#{absolute_range(range)}"
1439
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Area" && dn[:local_sheet_name] == sheet_name }
1440
+ defined_name("_xlnm.Print_Area", value, sheet: sheet_name)
1441
+ # simplecov:enable
1442
+ end
1443
+
1444
+ # Sets print titles for the current or named sheet.
1445
+ #
1446
+ # @param rows [String, nil] Repeating row range (e.g. "1:2").
1447
+ # @param cols [String, nil] Repeating column range (e.g. "A:B").
1448
+ # @param sheet [String, nil] Target sheet name.
1449
+ # @return [void]
1450
+ # @api public
1451
+ #: (?rows: String?, ?cols: String?, ?sheet: String?) -> void
1452
+ def print_titles(rows: nil, cols: nil, sheet: nil)
1453
+ sheet_name = sheet || @current_sheet || "Sheet1"
1454
+ parts = []
1455
+ parts << "'#{sheet_name}'!$#{cols.sub(":", ":$")}" if cols
1456
+ parts << "'#{sheet_name}'!$#{rows.sub(":", ":$")}" if rows
1457
+ value = parts.join(",")
1458
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Titles" && dn[:local_sheet_name] == sheet_name }
1459
+ defined_name("_xlnm.Print_Titles", value, sheet: sheet_name)
1460
+ end
1461
+
1462
+ # Sets workbook protection.
1463
+ #
1464
+ # @param opts [Hash] Protection options.
1465
+ # @return [void]
1466
+ # @api public
1467
+ #: (**String | Integer | bool | nil opts) -> void
1468
+ def protect_workbook(**opts)
1469
+ @workbook_protection = opts
1470
+ end
1471
+
1472
+ # Sets a core document metadata property.
1473
+ #
1474
+ # @param name [Symbol] Property name.
1475
+ # @param value [String, Integer, Time] Property value.
1476
+ # @return [void]
1477
+ # @api public
1478
+ #: (Symbol name, String | Integer | Time value) -> void
1479
+ def core_property(name, value)
1480
+ @core_properties[name] = value
1481
+ end
1482
+
1483
+ # Sets an app document property.
1484
+ #
1485
+ # @param name [Symbol] Property name.
1486
+ # @param value [String, Integer, Time] Property value.
1487
+ # @return [void]
1488
+ # @api public
1489
+ #: (Symbol name, String | Integer | Time value) -> void
1490
+ def app_property(name, value)
1491
+ # simplecov:disable
1492
+ # Edge case / untested delegation block
1493
+ @app_properties[name] = value
1494
+ # simplecov:enable
1495
+ end
1496
+
1497
+ # Sets multiple core and/or app properties.
1498
+ #
1499
+ # @param core [Hash, nil] Core properties.
1500
+ # @param app [Hash, nil] App properties.
1501
+ # @param custom [Hash, nil] Custom properties.
1502
+ # @return [void]
1503
+ # @api public
1504
+ #: (?core: Hash[Symbol, String | Integer | Time]?, ?app: Hash[Symbol, String | Integer | Time]?, ?custom: Hash[String | Symbol, untyped]?) -> void
1505
+ def properties(core: nil, app: nil, custom: nil)
1506
+ # simplecov:disable
1507
+ # Edge case / untested delegation block
1508
+ core&.each { |k, v| core_property(k, v) }
1509
+ app&.each { |k, v| app_property(k, v) }
1510
+ custom&.each { |k, v| custom_property(k.to_s, v) }
1511
+ # simplecov:enable
1512
+ end
1513
+
1514
+ # Adds a custom document property.
1515
+ #
1516
+ # @param name [String] Property name.
1517
+ # @param value [String, Integer, Float, Boolean, Time] Property value.
1518
+ # @param type [Symbol] Value type.
1519
+ # @return [void]
1520
+ # @api public
1521
+ #: (String name, String | Integer | Float | bool | Time value, ?type: ::Symbol) -> void
1522
+ def custom_property(name, value, type: :string)
1523
+ # simplecov:disable
1524
+ # Edge case / untested delegation block
1525
+ @custom_properties << { name: name, value: value, type: type }
1526
+ # simplecov:enable
1527
+ end
1528
+
1529
+ # Finalizes and writes all streaming sheet contents to the destination target.
1530
+ #
1531
+ # @return [void]
1532
+ # @api public
1533
+ #: () -> untyped
1534
+ def close
1535
+ raise ArgumentError, "Workbook must contain at least one sheet (Excel limitation)" if @strict_excel_mode && @sheets.empty? && @current_sheet.nil?
1536
+
1537
+ Xlsxrb.in_span("StreamWriter#close") do
1538
+ flush_current_sheet
1539
+
1540
+ styles_definition = {
1541
+ fonts: @style_writer.fonts.dup,
1542
+ fills: @style_writer.fills.dup,
1543
+ borders: @style_writer.borders.dup,
1544
+ xf_entries: @style_writer.xf_entries.dup,
1545
+ num_fmts: @style_writer.num_fmts.dup
1546
+ }
1547
+
1548
+ resolved_names = resolve_defined_names(@defined_names, @sheets)
1549
+
1550
+ Ooxml::WorkbookWriter.write(
1551
+ @target,
1552
+ sheets: @sheets,
1553
+ shared_strings: @sst,
1554
+ styles: styles_definition,
1555
+ defined_names: resolved_names.empty? ? nil : resolved_names,
1556
+ core_properties: @core_properties.empty? ? nil : @core_properties,
1557
+ app_properties: @app_properties.empty? ? nil : @app_properties,
1558
+ custom_properties: @custom_properties.empty? ? nil : @custom_properties,
1559
+ workbook_protection: @workbook_protection
1560
+ )
1561
+ end
1562
+ ensure
1563
+ cleanup!
1564
+ end
1565
+
1566
+ # Explicitly removes any temporary files created during streaming.
1567
+ #
1568
+ # @return [void]
1569
+ # @api public
1570
+ #: () -> void
1571
+ def cleanup!
1572
+ @tempfiles.each do |tmp|
1573
+ tmp.close
1574
+ tmp.unlink
1575
+ end
1576
+ @tempfiles.clear
1577
+ end
1578
+
1579
+ private
1580
+
1581
+ #: (untyped range) -> untyped
1582
+ def absolute_range(range)
1583
+ # simplecov:disable
1584
+ # Edge case / untested delegation block
1585
+ range.gsub(/([A-Z]+)(\d+)/, '$\1$\2')
1586
+ # simplecov:enable
1587
+ end
1588
+
1589
+ #: (untyped names, untyped sheets) -> untyped
1590
+ def resolve_defined_names(names, sheets)
1591
+ sheet_names = sheets.map { |s| s[:name] }
1592
+ names.map do |dn|
1593
+ resolved = dn.dup
1594
+ if dn[:local_sheet_name]
1595
+ idx = sheet_names.index(dn[:local_sheet_name])
1596
+ resolved[:local_sheet_id] = idx if idx
1597
+ resolved.delete(:local_sheet_name)
1598
+ end
1599
+ resolved
1600
+ end
1601
+ end
1602
+
1603
+ #: () -> (nil | untyped)
1604
+ def flush_current_sheet
1605
+ return unless @current_sheet
1606
+
1607
+ @current_tempfile.close
1608
+
1609
+ sheet_data = { name: @current_sheet, rows_tmp_path: @current_tempfile.path, columns: @current_columns }
1610
+ sheet_data[:cells] = @current_cells if @current_cells && !@current_cells.empty?
1611
+ @current_cells = nil
1612
+ sheet_data[:charts] = @current_charts unless @current_charts.empty?
1613
+ sheet_data[:hyperlinks] = @current_hyperlinks unless @current_hyperlinks.empty?
1614
+ sheet_data[:auto_filter] = @current_auto_filter if @current_auto_filter
1615
+ sheet_data[:filter_columns] = @current_filter_columns unless @current_filter_columns.empty?
1616
+ sheet_data[:sort_state] = @current_sort_state if @current_sort_state
1617
+ sheet_data[:data_validations] = @current_data_validations unless @current_data_validations.empty?
1618
+ sheet_data[:conditional_formats] = @current_conditional_formats unless @current_conditional_formats.empty?
1619
+ sheet_data[:tables] = @current_tables unless @current_tables.empty?
1620
+ sheet_data[:pivot_tables] = @current_pivot_tables unless @current_pivot_tables.empty?
1621
+ sheet_data[:sparkline_groups] = @current_sparkline_groups unless @current_sparkline_groups.empty?
1622
+ sheet_data[:comments] = @current_comments unless @current_comments.empty?
1623
+ sheet_data[:merge_cells] = @current_merge_cells unless @current_merge_cells.empty?
1624
+ sheet_data[:freeze_pane] = @current_freeze_pane if @current_freeze_pane
1625
+ sheet_data[:split_pane] = @current_split_pane if @current_split_pane
1626
+ sheet_data[:selection] = @current_selection if @current_selection
1627
+ sheet_data[:page_margins] = @current_page_margins if @current_page_margins
1628
+ sheet_data[:page_setup] = @current_page_setup unless @current_page_setup.empty?
1629
+ sheet_data[:header_footer] = @current_header_footer unless @current_header_footer.empty?
1630
+ sheet_data[:print_options] = @current_print_options unless @current_print_options.empty?
1631
+ sheet_data[:sheet_protection] = @current_sheet_protection if @current_sheet_protection
1632
+ sheet_data[:images] = @current_images unless @current_images.empty?
1633
+ sheet_data[:shapes] = @current_shapes unless @current_shapes.empty?
1634
+ sheet_data[:sheet_properties] = @current_sheet_properties unless @current_sheet_properties.empty?
1635
+ sheet_data[:sheet_view] = @current_sheet_view unless @current_sheet_view.empty?
1636
+ sheet_data[:row_breaks] = @current_row_breaks unless @current_row_breaks.empty?
1637
+ sheet_data[:col_breaks] = @current_col_breaks unless @current_col_breaks.empty?
1638
+ @sheets << sheet_data
1639
+
1640
+ @tempfiles << @current_tempfile
1641
+ @current_sheet = nil
1642
+ @current_tempfile = nil
1643
+ @current_row_writer = nil
1644
+ end
1645
+ end
1646
+ end