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
data/lib/xlsxrb.rb ADDED
@@ -0,0 +1,1427 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "tempfile"
7
+ require "opentelemetry"
8
+ require_relative "xlsxrb/version"
9
+ require_relative "xlsxrb/ooxml/zip_generator"
10
+ require_relative "xlsxrb/ooxml/writer"
11
+ require_relative "xlsxrb/ooxml/reader"
12
+ require_relative "xlsxrb/ooxml"
13
+ require_relative "xlsxrb/elements"
14
+ require_relative "xlsxrb/style_builder"
15
+
16
+ # Ruby XLSX read/write library.
17
+ module Xlsxrb
18
+ class Error < StandardError; end
19
+
20
+ TRACER = OpenTelemetry.tracer_provider.tracer("xlsxrb", Xlsxrb::VERSION)
21
+
22
+ # Builder for block-style chart definitions.
23
+ class ChartBuilder
24
+ def initialize
25
+ @options = {}
26
+ end
27
+
28
+ attr_reader :options
29
+
30
+ def type(value) = @options[:type] = value
31
+ def title(value) = @options[:title] = value
32
+
33
+ def series(value = nil, &block)
34
+ @options[:series] ||= []
35
+ if block_given?
36
+ sb = SeriesBuilder.new
37
+ block.call(sb)
38
+ @options[:series] << sb.options
39
+ elsif value
40
+ @options[:series] << value
41
+ end
42
+ @options[:series]
43
+ end
44
+
45
+ def method_missing(name, *args, **kwargs, &)
46
+ key = name.to_sym
47
+ @options[key] = kwargs.empty? ? args.first : kwargs
48
+ end
49
+
50
+ def respond_to_missing?(_name, _include_private = false)
51
+ true
52
+ end
53
+
54
+ # Builder for a single series entry in block-style chart definitions.
55
+ class SeriesBuilder
56
+ def initialize
57
+ @options = {}
58
+ end
59
+
60
+ attr_reader :options
61
+
62
+ def method_missing(name, *args, **kwargs, &)
63
+ key = name.to_sym
64
+ @options[key] = kwargs.empty? ? args.first : kwargs
65
+ end
66
+
67
+ def respond_to_missing?(_name, _include_private = false)
68
+ true
69
+ end
70
+ end
71
+ end
72
+
73
+ # Generic builder for block-style feature definitions.
74
+ # Supports method_missing for setting arbitrary keys.
75
+ # --- Facade API ---
76
+
77
+ # Creates a Formula object for use in add_row values.
78
+ # expression: the formula text (e.g. "SUM(A1:A10)")
79
+ # cached_value: optional cached result. If nil, Excel will calculate on open.
80
+ def self.formula(expression, cached_value: nil)
81
+ Elements::Formula.new(
82
+ expression: expression,
83
+ cached_value: cached_value,
84
+ calculate_always: cached_value.nil? || nil
85
+ )
86
+ end
87
+
88
+ # Reads an XLSX file into an Elements::Workbook.
89
+ # source: file path (String) or IO object.
90
+ def self.read(source)
91
+ attributes = source.is_a?(String) ? { "filepath" => source } : {}
92
+ TRACER.in_span("Xlsxrb.read", attributes: attributes) do
93
+ entries = Ooxml::ZipReader.open(source, &:read_all)
94
+ shared_strings = Ooxml::SharedStringsParser.parse(entries["xl/sharedStrings.xml"])
95
+ styles = Ooxml::StylesParser.parse(entries["xl/styles.xml"])
96
+ workbook_sheets = Ooxml::WorkbookParser.parse(entries["xl/workbook.xml"])
97
+ rels = Ooxml::RelationshipsParser.parse(entries["xl/_rels/workbook.xml.rels"])
98
+
99
+ sheets = workbook_sheets.map do |sheet_info|
100
+ target = rels[sheet_info[:r_id]]
101
+ next nil unless target
102
+
103
+ sheet_path = target.start_with?("/") ? target.delete_prefix("/") : "xl/#{target}"
104
+ sheet_xml = entries[sheet_path]
105
+ build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles)
106
+ end.compact
107
+
108
+ Elements::Workbook.new(sheets: sheets, shared_strings: shared_strings, styles: styles)
109
+ end
110
+ end
111
+
112
+ # Writes an Elements::Workbook to an XLSX file.
113
+ # target: file path (String) or IO object.
114
+ def self.write(target, workbook)
115
+ raise Error, "target is required" if target.nil?
116
+ raise Error, "workbook must be an Elements::Workbook" unless workbook.is_a?(Elements::Workbook)
117
+
118
+ attributes = target.is_a?(String) ? { "filepath" => target } : {}
119
+ TRACER.in_span("Xlsxrb.write", attributes: attributes) do
120
+ sst = []
121
+ sst_index = {}
122
+
123
+ # Collect shared strings and build index without allocating new Hashes
124
+ sheet_data = workbook.sheets.map do |ws|
125
+ ws.rows.each do |row|
126
+ row.cells.each do |cell|
127
+ val = cell.value
128
+ if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
129
+ sst << val
130
+ sst_index[val] = sst.size - 1
131
+ end
132
+ end
133
+ end
134
+ columns = ws.columns.map do |col|
135
+ { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level }
136
+ end
137
+ sd = { name: ws.name, rows: ws.rows, columns: columns }
138
+ sd[:charts] = ws.charts unless ws.charts.empty?
139
+
140
+ # Extract facade metadata from unmapped_data
141
+ facade = ws.unmapped_data[:facade]
142
+ facade&.each { |key, val| sd[key] = val }
143
+
144
+ sd
145
+ end
146
+
147
+ # Extract workbook-level facade metadata
148
+ wb_facade = workbook.unmapped_data[:facade] || {}
149
+ Ooxml::WorkbookWriter.write(
150
+ target,
151
+ sheets: sheet_data,
152
+ shared_strings: sst,
153
+ shared_strings_index: sst_index,
154
+ styles: workbook.styles,
155
+ defined_names: wb_facade[:defined_names],
156
+ core_properties: wb_facade[:core_properties],
157
+ app_properties: wb_facade[:app_properties],
158
+ custom_properties: wb_facade[:custom_properties],
159
+ workbook_protection: wb_facade[:workbook_protection]
160
+ )
161
+ end
162
+ end
163
+
164
+ # Modifies an existing XLSX file.
165
+ # Reads the workbook, passes it to the block, and writes the result.
166
+ # The block receives an Elements::Workbook and must return a modified one (e.g. via `with`).
167
+ # If no target is given, the source is overwritten.
168
+ #
169
+ # Example:
170
+ # Xlsxrb.modify("template.xlsx", "output.xlsx") do |wb|
171
+ # sheet = wb.sheet(0)
172
+ # row0 = sheet.row_at(0)
173
+ # new_cell = Xlsxrb::Elements::Cell.new(row_index: 0, column_index: 1, value: "Updated")
174
+ # new_row = row0.with(cells: row0.cells.map { |c| c.column_index == 1 ? new_cell : c })
175
+ # new_sheet = sheet.with(rows: sheet.rows.map { |r| r.index == 0 ? new_row : r })
176
+ # wb.with(sheets: wb.sheets.map.with_index { |s, i| i == 0 ? new_sheet : s })
177
+ # end
178
+ def self.modify(source, target = nil, &block)
179
+ raise Error, "source is required" if source.nil?
180
+ raise Error, "block is required" unless block
181
+
182
+ workbook = read(source)
183
+ result_workbook = block.call(workbook)
184
+ result_workbook = workbook unless result_workbook.is_a?(Elements::Workbook)
185
+
186
+ write_target = target || source
187
+ write(write_target, result_workbook)
188
+ end
189
+
190
+ # Streaming read: yields Elements::Row one at a time.
191
+ # source: file path (String) or IO object.
192
+ # Options:
193
+ # sheet: sheet index (0-based Integer) or name (String). Defaults to 0.
194
+ def self.foreach(source, sheet: 0, &block)
195
+ return enum_for(:foreach, source, sheet: sheet) unless block
196
+
197
+ attributes = source.is_a?(String) ? { "filepath" => source } : {}
198
+ TRACER.in_span("Xlsxrb.foreach", attributes: attributes) do
199
+ entries = Ooxml::ZipReader.open(source, &:read_all)
200
+ shared_strings = Ooxml::SharedStringsParser.parse(entries["xl/sharedStrings.xml"])
201
+ workbook_sheets = Ooxml::WorkbookParser.parse(entries["xl/workbook.xml"])
202
+ rels = Ooxml::RelationshipsParser.parse(entries["xl/_rels/workbook.xml.rels"])
203
+
204
+ target_sheet = case sheet
205
+ when Integer
206
+ workbook_sheets[sheet]
207
+ when String
208
+ workbook_sheets.find { |s| s[:name] == sheet }
209
+ end
210
+ next unless target_sheet
211
+
212
+ target = rels[target_sheet[:r_id]]
213
+ next unless target
214
+
215
+ sheet_path = target.start_with?("/") ? target.delete_prefix("/") : "xl/#{target}"
216
+ sheet_xml = entries[sheet_path]
217
+ next if sheet_xml.nil? || sheet_xml.empty?
218
+
219
+ Ooxml::WorksheetParser.each_row(sheet_xml, shared_strings: shared_strings) do |raw_row|
220
+ row = build_row_from_raw(raw_row)
221
+ block.call(row)
222
+ end
223
+ end
224
+ end
225
+
226
+ # Streaming write: yields a StreamWriter context for building XLSX on-the-fly.
227
+ # target: file path (String) or IO object.
228
+ def self.generate(target, &block)
229
+ raise Error, "target is required" if target.nil?
230
+ raise Error, "block is required" unless block
231
+
232
+ attributes = target.is_a?(String) ? { "filepath" => target } : {}
233
+ TRACER.in_span("Xlsxrb.generate", attributes: attributes) do
234
+ stream_writer = StreamWriter.new(target)
235
+ block.call(stream_writer)
236
+ stream_writer.close
237
+ end
238
+ end
239
+
240
+ # Builds an Elements::Workbook in memory using a DSL.
241
+ def self.build(&block)
242
+ raise Error, "block is required" unless block
243
+
244
+ TRACER.in_span("Xlsxrb.build") do
245
+ builder = WorkbookBuilder.new
246
+ block.call(builder)
247
+ builder.build
248
+ end
249
+ end
250
+
251
+ # DSL context for Xlsxrb.build.
252
+ class WorkbookBuilder
253
+ def initialize
254
+ @sheets = []
255
+ @sheet_builders = [] # Keep track of sheet builders for style processing
256
+ @defined_names = []
257
+ @core_properties = {}
258
+ @app_properties = {}
259
+ @custom_properties = []
260
+ @workbook_protection = nil
261
+ end
262
+
263
+ # Add a new sheet.
264
+ def add_sheet(name = nil, &block)
265
+ name ||= "Sheet#{@sheets.size + 1}"
266
+ sheet_builder = WorksheetBuilder.new(name)
267
+ block.call(sheet_builder) if block_given?
268
+ @sheet_builders << sheet_builder
269
+ @sheets << sheet_builder.build
270
+ end
271
+
272
+ # --- Workbook-Level Methods ---
273
+
274
+ # Add a defined name.
275
+ def add_defined_name(name, value, sheet: nil, hidden: false)
276
+ entry = { name: name, value: value, hidden: hidden }
277
+ entry[:local_sheet_name] = sheet if sheet
278
+ @defined_names << entry
279
+ end
280
+
281
+ # Set the print area for a sheet.
282
+ def set_print_area(range, sheet: nil)
283
+ sheet_name = sheet || @sheets.last&.name || "Sheet1"
284
+ value = "'#{sheet_name}'!#{absolute_range(range)}"
285
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Area" && dn[:local_sheet_name] == sheet_name }
286
+ add_defined_name("_xlnm.Print_Area", value, sheet: sheet_name)
287
+ end
288
+
289
+ # Set print titles for a sheet.
290
+ def set_print_titles(rows: nil, cols: nil, sheet: nil)
291
+ sheet_name = sheet || @sheets.last&.name || "Sheet1"
292
+ parts = []
293
+ parts << "'#{sheet_name}'!$#{cols.sub(":", ":$")}" if cols
294
+ parts << "'#{sheet_name}'!$#{rows.sub(":", ":$")}" if rows
295
+ value = parts.join(",")
296
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Titles" && dn[:local_sheet_name] == sheet_name }
297
+ add_defined_name("_xlnm.Print_Titles", value, sheet: sheet_name)
298
+ end
299
+
300
+ # Set workbook protection.
301
+ def set_workbook_protection(**opts)
302
+ @workbook_protection = opts
303
+ end
304
+
305
+ # Set a core document property.
306
+ def set_core_property(name, value)
307
+ @core_properties[name] = value
308
+ end
309
+
310
+ # Set an app document property.
311
+ def set_app_property(name, value)
312
+ @app_properties[name] = value
313
+ end
314
+
315
+ # Add a custom document property.
316
+ def add_custom_property(name, value, type: :string)
317
+ @custom_properties << { name: name, value: value, type: type }
318
+ end
319
+
320
+ def build
321
+ # Process styles from all sheets and collect style definitions
322
+ processed_sheets, styles_definition = process_styles(@sheets)
323
+
324
+ # Store workbook-level metadata in unmapped_data
325
+ wb_meta = {}
326
+ wb_meta[:defined_names] = resolve_defined_names(@defined_names, processed_sheets) unless @defined_names.empty?
327
+ wb_meta[:core_properties] = @core_properties unless @core_properties.empty?
328
+ wb_meta[:app_properties] = @app_properties unless @app_properties.empty?
329
+ wb_meta[:custom_properties] = @custom_properties unless @custom_properties.empty?
330
+ wb_meta[:workbook_protection] = @workbook_protection if @workbook_protection
331
+
332
+ Elements::Workbook.new(
333
+ sheets: processed_sheets,
334
+ styles: styles_definition,
335
+ unmapped_data: wb_meta.empty? ? {} : { facade: wb_meta }
336
+ )
337
+ end
338
+
339
+ private
340
+
341
+ def absolute_range(range)
342
+ range.gsub(/([A-Z]+)(\d+)/, '$\1$\2')
343
+ end
344
+
345
+ def resolve_defined_names(names, sheets)
346
+ sheet_names = sheets.map(&:name)
347
+ names.map do |dn|
348
+ resolved = dn.dup
349
+ if dn[:local_sheet_name]
350
+ idx = sheet_names.index(dn[:local_sheet_name])
351
+ resolved[:local_sheet_id] = idx if idx
352
+ resolved.delete(:local_sheet_name)
353
+ end
354
+ resolved
355
+ end
356
+ end
357
+
358
+ def process_styles(sheets)
359
+ # Collect all unique StyleBuilders from all sheets
360
+ all_style_builders = {}
361
+ @sheet_builders.each do |sheet_builder|
362
+ sheet_builder.styles.each do |style_name, style_builder|
363
+ all_style_builders[style_name] = style_builder
364
+ end
365
+ end
366
+
367
+ return [sheets, {}] if all_style_builders.empty?
368
+
369
+ # Create a temporary writer to register styles and get numeric IDs
370
+ temp_writer = Ooxml::Writer.new
371
+ style_name_to_id = {}
372
+ all_style_builders.each do |name, builder|
373
+ style_id = builder.register_with(temp_writer)
374
+ style_name_to_id[name] = style_id
375
+ end
376
+
377
+ # Capture the style definitions from the temporary writer
378
+ styles_definition = extract_styles_from_writer(temp_writer)
379
+
380
+ # Update all cells with their resolved style IDs
381
+ updated_sheets = sheets.map do |sheet|
382
+ new_rows = sheet.rows.map do |row|
383
+ new_cells = row.cells.map do |cell|
384
+ # If style_index is a string (style name), resolve it to a numeric ID
385
+ if cell.style_index.is_a?(String) && style_name_to_id.key?(cell.style_index)
386
+ Elements::Cell.new(
387
+ row_index: cell.row_index,
388
+ column_index: cell.column_index,
389
+ value: cell.value,
390
+ formula: cell.formula,
391
+ style_index: style_name_to_id[cell.style_index],
392
+ unmapped_data: cell.unmapped_data,
393
+ errors: cell.errors
394
+ )
395
+ else
396
+ cell
397
+ end
398
+ end
399
+ Elements::Row.new(
400
+ index: row.index,
401
+ cells: new_cells,
402
+ height: row.height,
403
+ hidden: row.hidden,
404
+ custom_height: row.custom_height,
405
+ outline_level: row.outline_level,
406
+ unmapped_data: row.unmapped_data,
407
+ errors: row.errors
408
+ )
409
+ end
410
+ Elements::Worksheet.new(
411
+ name: sheet.name,
412
+ rows: new_rows,
413
+ columns: sheet.columns,
414
+ charts: sheet.charts,
415
+ unmapped_data: sheet.unmapped_data,
416
+ errors: sheet.errors
417
+ )
418
+ end
419
+
420
+ [updated_sheets, styles_definition]
421
+ end
422
+
423
+ def extract_styles_from_writer(writer)
424
+ # Extract style definitions from the writer that can be reused
425
+ # This captures the fonts, fills, borders, and xf entries that were created
426
+ {
427
+ fonts: writer.instance_variable_get(:@fonts).dup,
428
+ fills: writer.instance_variable_get(:@fills).dup,
429
+ borders: writer.instance_variable_get(:@borders).dup,
430
+ xf_entries: writer.instance_variable_get(:@xf_entries).dup,
431
+ num_fmts: writer.instance_variable_get(:@num_fmts).dup
432
+ }
433
+ end
434
+ end
435
+
436
+ # DSL context for a single worksheet in Xlsxrb.build.
437
+ class WorksheetBuilder
438
+ def initialize(name)
439
+ @name = name
440
+ @rows = []
441
+ @columns = []
442
+ @charts = []
443
+ @styles = {} # { style_name => StyleBuilder }
444
+ @style_index_map = {} # { style_name => xf_index } (populated at build time)
445
+ @hyperlinks = []
446
+ @auto_filter = nil
447
+ @filter_columns = {}
448
+ @sort_state = nil
449
+ @data_validations = []
450
+ @conditional_formats = []
451
+ @tables = []
452
+ @comments = []
453
+ @sparkline_groups = []
454
+ @merge_cells_ranges = []
455
+ @freeze_pane = nil
456
+ @split_pane = nil
457
+ @selection = nil
458
+ @page_margins = nil
459
+ @page_setup = {}
460
+ @header_footer = {}
461
+ @print_options = {}
462
+ @sheet_protection = nil
463
+ @images = []
464
+ @shapes = []
465
+ @sheet_properties = {}
466
+ @sheet_view = {}
467
+ @row_breaks = []
468
+ @col_breaks = []
469
+ end
470
+
471
+ # Define a named style that can be applied to cells.
472
+ def add_style(name, **opts, &block)
473
+ style_builder = StyleBuilder.new(name)
474
+ style_builder.apply_options!(**opts) unless opts.empty?
475
+ block.call(style_builder) if block_given?
476
+ @styles[name] = style_builder
477
+ style_builder
478
+ end
479
+
480
+ # Add a row of values to the sheet.
481
+ # values:: Array of cell values
482
+ # styles:: Hash mapping column indices to style names, or Array of style names for each column
483
+ def add_row(values, styles: nil, height: nil, hidden: false, custom_height: false, outline_level: nil)
484
+ row_index = @rows.size
485
+ cells = Array.new(values.size)
486
+ style_lookup = styles.is_a?(Hash) || styles.is_a?(Array)
487
+
488
+ col_index = 0
489
+ while col_index < values.size
490
+ val = values[col_index]
491
+ style_name = style_lookup ? styles[col_index] : nil
492
+ # If value is a Formula object, store it as the cell's formula
493
+ cells[col_index] = if val.is_a?(Elements::Formula)
494
+ Elements::Cell.new(
495
+ row_index: row_index,
496
+ column_index: col_index,
497
+ value: nil,
498
+ formula: val,
499
+ style_index: style_name
500
+ )
501
+ else
502
+ Elements::Cell.new(
503
+ row_index: row_index,
504
+ column_index: col_index,
505
+ value: val,
506
+ style_index: style_name
507
+ )
508
+ end
509
+ col_index += 1
510
+ end
511
+
512
+ @rows << Elements::Row.new(
513
+ index: row_index,
514
+ cells: cells,
515
+ height: height,
516
+ hidden: hidden,
517
+ custom_height: custom_height || !height.nil?,
518
+ outline_level: outline_level
519
+ )
520
+ end
521
+
522
+ # Set column width for a 0-based column index.
523
+ def set_column(index, width: nil, hidden: false, custom_width: false, outline_level: nil)
524
+ @columns << Elements::Column.new(
525
+ index: index,
526
+ width: width,
527
+ hidden: hidden,
528
+ custom_width: custom_width || !width.nil?,
529
+ outline_level: outline_level
530
+ )
531
+ end
532
+
533
+ # Add a chart to the sheet.
534
+ def add_chart(**options, &block)
535
+ if block_given?
536
+ builder = ChartBuilder.new
537
+ block.call(builder)
538
+ options = builder.options.merge(options)
539
+ end
540
+ @charts << options
541
+ end
542
+
543
+ # --- Hyperlinks ---
544
+
545
+ # Add a hyperlink on a cell.
546
+ def add_hyperlink(cell, url = nil, display: nil, tooltip: nil, location: nil)
547
+ link = { cell: cell }
548
+ link[:url] = url if url
549
+ link[:display] = display if display
550
+ link[:tooltip] = tooltip if tooltip
551
+ link[:location] = location if location
552
+ @hyperlinks << link
553
+ end
554
+
555
+ # --- Auto Filter / Sort ---
556
+
557
+ # Set an auto filter range (e.g. "A1:D10").
558
+ # rubocop:disable Naming/AccessorMethodName
559
+ def set_auto_filter(range)
560
+ @auto_filter = range
561
+ end
562
+ # rubocop:enable Naming/AccessorMethodName
563
+
564
+ # Add a filter column to the auto filter.
565
+ def add_filter_column(col_id, filter)
566
+ @filter_columns[col_id] = filter
567
+ end
568
+
569
+ # Set sort state.
570
+ def set_sort_state(ref, sort_conditions, **opts)
571
+ @sort_state = { ref: ref, sort_conditions: sort_conditions }.merge(opts)
572
+ end
573
+
574
+ # --- Data Validation ---
575
+
576
+ # Add a data validation rule.
577
+ def add_data_validation(sqref, **opts)
578
+ @data_validations << opts.merge(sqref: sqref)
579
+ end
580
+
581
+ # --- Conditional Formatting ---
582
+
583
+ # Add a conditional formatting rule.
584
+ def add_conditional_format(sqref, **opts)
585
+ @conditional_formats << opts.merge(sqref: sqref)
586
+ end
587
+
588
+ # --- Tables ---
589
+
590
+ # Add a table to the sheet.
591
+ def add_table(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
592
+ tbl = { ref: ref, columns: columns }
593
+ tbl[:name] = name if name
594
+ tbl[:display_name] = display_name if display_name
595
+ tbl[:style] = style if style
596
+ tbl.merge!(opts)
597
+ @tables << tbl
598
+ end
599
+
600
+ # --- Pivot Tables ---
601
+
602
+ # Add a pivot table to the sheet.
603
+ # source_ref: data source range (e.g. "Sheet1!A1:C10")
604
+ # row_fields: array of 0-based field indices for row axis
605
+ # data_fields: array of { fld:, name:, subtotal: } hashes
606
+ # col_fields: array of 0-based field indices for column axis
607
+ # dest_ref: top-left cell for the pivot table (default "E1")
608
+ def add_pivot_table(source_ref, row_fields:, data_fields:, col_fields: [], dest_ref: "E1", name: nil, field_names: nil, items: nil)
609
+ @pivot_tables ||= []
610
+ @pivot_tables << {
611
+ source_ref: source_ref, row_fields: row_fields,
612
+ data_fields: data_fields, col_fields: col_fields,
613
+ dest_ref: dest_ref, name: name,
614
+ field_names: field_names, items: items
615
+ }
616
+ end
617
+
618
+ # --- Comments ---
619
+
620
+ # Add a comment on a cell.
621
+ def add_comment(cell, text, author: "Author")
622
+ @comments << { cell: cell, text: text, author: author }
623
+ end
624
+
625
+ # --- Sparklines ---
626
+
627
+ # Add a sparkline group to the sheet.
628
+ # sparklines: Array of { data_ref:, location_ref: } hashes
629
+ # type: "line" (default), "column", or "stacked"
630
+ def add_sparkline_group(sparklines:, type: nil, **opts)
631
+ group = { sparklines: sparklines }
632
+ group[:type] = type if type
633
+ group.merge!(opts)
634
+ @sparkline_groups << group
635
+ end
636
+
637
+ # --- Merge Cells ---
638
+
639
+ # Merge a range of cells (e.g. "A1:B2").
640
+ def merge_cells(range)
641
+ @merge_cells_ranges << range
642
+ end
643
+
644
+ # --- Freeze / Split Panes ---
645
+
646
+ # Freeze panes at the given row and column.
647
+ def set_freeze_pane(row: 0, col: 0)
648
+ @freeze_pane = { row: row, col: col }
649
+ end
650
+
651
+ # Split panes (non-frozen).
652
+ def set_split_pane(x_split: 0, y_split: 0, top_left_cell: nil)
653
+ @split_pane = { x_split: x_split, y_split: y_split, top_left_cell: top_left_cell }
654
+ end
655
+
656
+ # Set active cell selection.
657
+ def set_selection(active_cell, sqref: nil, pane: nil)
658
+ @selection = { active_cell: active_cell, sqref: sqref || active_cell }
659
+ @selection[:pane] = pane if pane
660
+ end
661
+
662
+ # --- Page Setup / Margins / Print ---
663
+
664
+ # Set page margins (in inches).
665
+ def set_page_margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
666
+ @page_margins = { left: left, right: right, top: top, bottom: bottom, header: header, footer: footer }.compact
667
+ end
668
+
669
+ # Set page setup properties.
670
+ def set_page_setup(**opts)
671
+ @page_setup.merge!(opts)
672
+ end
673
+
674
+ # Set header/footer text.
675
+ def set_header_footer(**opts)
676
+ @header_footer.merge!(opts)
677
+ end
678
+
679
+ # Set a print option.
680
+ def set_print_option(name, value)
681
+ @print_options[name] = value
682
+ end
683
+
684
+ # --- Sheet Protection ---
685
+
686
+ # Set sheet protection options.
687
+ def set_sheet_protection(**opts)
688
+ normalized = opts.dup
689
+ plain_password = normalized[:password]
690
+ needs_hash = plain_password.is_a?(String) && !plain_password.empty? &&
691
+ normalized[:algorithm_name].nil? && normalized[:hash_value].nil? &&
692
+ normalized[:salt_value].nil? && normalized[:spin_count].nil? &&
693
+ !plain_password.match?(/\A[0-9A-Fa-f]{4}\z/)
694
+ if needs_hash
695
+ normalized.delete(:password)
696
+ normalized.merge!(Xlsxrb::Ooxml::Utils.hash_password(plain_password))
697
+ end
698
+ @sheet_protection = normalized
699
+ end
700
+
701
+ # --- Images ---
702
+
703
+ # Insert an image from raw file data.
704
+ def add_image(file_data, ext: "png", from_col: 0, from_row: 0, to_col: 5, to_row: 10, **opts)
705
+ img = { file_data: file_data, ext: ext, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
706
+ img.merge!(opts)
707
+ @images << img
708
+ end
709
+
710
+ # --- Shapes ---
711
+
712
+ # Add a shape to the sheet.
713
+ def add_shape(preset: "rect", text: nil, from_col: 0, from_row: 0, to_col: 5, to_row: 5, **opts)
714
+ shape = { preset: preset, text: text, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
715
+ shape[:name] = opts.delete(:name) || "Shape #{@shapes.size + 1}"
716
+ shape.merge!(opts)
717
+ @shapes << shape
718
+ end
719
+
720
+ # --- Sheet Properties ---
721
+
722
+ # Set a sheet-level property (e.g. :tab_color).
723
+ def set_sheet_property(name, value)
724
+ @sheet_properties[name] = value
725
+ end
726
+
727
+ # Set a sheet view property (e.g. :show_grid_lines, :zoom_scale).
728
+ def set_sheet_view(name, value)
729
+ @sheet_view[name] = value
730
+ end
731
+
732
+ # --- Row / Column Breaks ---
733
+
734
+ # Add a page break before a row.
735
+ def add_row_break(row_num)
736
+ @row_breaks << row_num
737
+ end
738
+
739
+ # Add a page break before a column.
740
+ def add_col_break(col_index)
741
+ @col_breaks << col_index
742
+ end
743
+
744
+ def build
745
+ facade_meta = {}
746
+ facade_meta[:hyperlinks] = @hyperlinks unless @hyperlinks.empty?
747
+ facade_meta[:auto_filter] = @auto_filter if @auto_filter
748
+ facade_meta[:filter_columns] = @filter_columns unless @filter_columns.empty?
749
+ facade_meta[:sort_state] = @sort_state if @sort_state
750
+ facade_meta[:data_validations] = @data_validations unless @data_validations.empty?
751
+ facade_meta[:conditional_formats] = @conditional_formats unless @conditional_formats.empty?
752
+ facade_meta[:tables] = @tables unless @tables.empty?
753
+ facade_meta[:pivot_tables] = @pivot_tables unless (@pivot_tables || []).empty?
754
+ facade_meta[:comments] = @comments unless @comments.empty?
755
+ facade_meta[:sparkline_groups] = @sparkline_groups unless @sparkline_groups.empty?
756
+ facade_meta[:merge_cells] = @merge_cells_ranges unless @merge_cells_ranges.empty?
757
+ facade_meta[:freeze_pane] = @freeze_pane if @freeze_pane
758
+ facade_meta[:split_pane] = @split_pane if @split_pane
759
+ facade_meta[:selection] = @selection if @selection
760
+ facade_meta[:page_margins] = @page_margins if @page_margins
761
+ facade_meta[:page_setup] = @page_setup unless @page_setup.empty?
762
+ facade_meta[:header_footer] = @header_footer unless @header_footer.empty?
763
+ facade_meta[:print_options] = @print_options unless @print_options.empty?
764
+ facade_meta[:sheet_protection] = @sheet_protection if @sheet_protection
765
+ facade_meta[:images] = @images unless @images.empty?
766
+ facade_meta[:shapes] = @shapes unless @shapes.empty?
767
+ facade_meta[:sheet_properties] = @sheet_properties unless @sheet_properties.empty?
768
+ facade_meta[:sheet_view] = @sheet_view unless @sheet_view.empty?
769
+ facade_meta[:row_breaks] = @row_breaks unless @row_breaks.empty?
770
+ facade_meta[:col_breaks] = @col_breaks unless @col_breaks.empty?
771
+
772
+ Elements::Worksheet.new(
773
+ name: @name, rows: @rows, columns: @columns, charts: @charts,
774
+ unmapped_data: facade_meta.empty? ? {} : { facade: facade_meta }
775
+ )
776
+ end
777
+
778
+ # Internal: returns styles for later processing by WorkbookBuilder
779
+ attr_reader :styles
780
+ end
781
+
782
+ # DSL context for Xlsxrb.generate streaming writes.
783
+ class StreamWriter
784
+ def initialize(target)
785
+ @target = target
786
+ @sst = []
787
+ @sst_index = {}
788
+ @sheets = []
789
+ @current_sheet = nil
790
+ @current_row_index = 0
791
+ @tempfiles = []
792
+ @current_tempfile = nil
793
+ @current_row_writer = nil
794
+ @current_columns = []
795
+ @current_charts = []
796
+ @current_hyperlinks = []
797
+ @current_auto_filter = nil
798
+ @current_filter_columns = {}
799
+ @current_sort_state = nil
800
+ @current_data_validations = []
801
+ @current_conditional_formats = []
802
+ @current_tables = []
803
+ @current_comments = []
804
+ @current_merge_cells = []
805
+ @current_freeze_pane = nil
806
+ @current_split_pane = nil
807
+ @current_selection = nil
808
+ @current_page_margins = nil
809
+ @current_page_setup = {}
810
+ @current_header_footer = {}
811
+ @current_print_options = {}
812
+ @current_sheet_protection = nil
813
+ @current_images = []
814
+ @current_shapes = []
815
+ @current_sheet_properties = {}
816
+ @current_sheet_view = {}
817
+ @current_row_breaks = []
818
+ @current_col_breaks = []
819
+ @styles = {} # { style_name => StyleBuilder }
820
+ @style_writer = Ooxml::Writer.new
821
+ @style_name_to_id = {}
822
+ # Workbook-level settings
823
+ @defined_names = []
824
+ @core_properties = {}
825
+ @app_properties = {}
826
+ @custom_properties = []
827
+ @workbook_protection = nil
828
+ end
829
+
830
+ # Define a named style that can be applied to cells.
831
+ def add_style(name, **opts, &block)
832
+ style_builder = StyleBuilder.new(name)
833
+ style_builder.apply_options!(**opts) unless opts.empty?
834
+ block.call(style_builder) if block_given?
835
+ @styles[name] = style_builder
836
+
837
+ # Register immediately
838
+ @style_name_to_id[name] = style_builder.register_with(@style_writer)
839
+
840
+ style_builder
841
+ end
842
+
843
+ # Start or switch to a named sheet.
844
+ def add_sheet(name = nil)
845
+ flush_current_sheet
846
+ name ||= "Sheet#{@sheets.size + 1}"
847
+ @current_sheet = name
848
+ @current_row_index = 0
849
+ @current_tempfile = Tempfile.new(["xlsxrb_rows", ".xml"])
850
+ @current_tempfile.binmode
851
+ @current_row_writer = Ooxml::WorksheetWriter.new(@current_tempfile)
852
+ @current_row_writer.instance_variable_set(:@started, true)
853
+
854
+ @current_columns = []
855
+ @current_charts = []
856
+ @current_hyperlinks = []
857
+ @current_auto_filter = nil
858
+ @current_filter_columns = {}
859
+ @current_sort_state = nil
860
+ @current_data_validations = []
861
+ @current_conditional_formats = []
862
+ @current_tables = []
863
+ @current_pivot_tables = []
864
+ @current_sparkline_groups = []
865
+ @current_comments = []
866
+ @current_merge_cells = []
867
+ @current_freeze_pane = nil
868
+ @current_split_pane = nil
869
+ @current_selection = nil
870
+ @current_page_margins = nil
871
+ @current_page_setup = {}
872
+ @current_header_footer = {}
873
+ @current_print_options = {}
874
+ @current_sheet_protection = nil
875
+ @current_images = []
876
+ @current_shapes = []
877
+ @current_sheet_properties = {}
878
+ @current_sheet_view = {}
879
+ @current_row_breaks = []
880
+ @current_col_breaks = []
881
+ @current_cells = {}
882
+
883
+ return unless block_given?
884
+
885
+ yield self
886
+ flush_current_sheet
887
+ end
888
+
889
+ # Add a row of values. values is an Array.
890
+ # styles:: Hash mapping column indices to style names, or Array of style names for each column
891
+ def add_row(values, styles: nil, height: nil, hidden: false, custom_height: false, outline_level: nil)
892
+ add_sheet if @current_sheet.nil?
893
+
894
+ row_index = @current_row_index
895
+ @current_row_index += 1
896
+
897
+ @current_cells ||= {}
898
+ row_num = row_index + 1
899
+ values.each_with_index do |val, col_idx|
900
+ next if val.nil?
901
+
902
+ addr = "#{Elements::Cell.column_letter(col_idx)}#{row_num}"
903
+ @current_cells[addr] = val
904
+ end
905
+
906
+ attrs = nil
907
+ if height || hidden || outline_level
908
+ attrs = {}
909
+ attrs[:height] = height if height
910
+ attrs[:hidden] = true if hidden
911
+ attrs[:custom_height] = custom_height || !height.nil?
912
+ attrs[:outline_level] = outline_level if outline_level
913
+ end
914
+
915
+ @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)
916
+ end
917
+
918
+ # Set column width for a 0-based column index.
919
+ def set_column(index, width: nil, hidden: false, custom_width: false, outline_level: nil)
920
+ add_sheet if @current_sheet.nil?
921
+
922
+ @current_columns << { index: index, width: width, hidden: hidden, custom_width: custom_width || !width.nil?, outline_level: outline_level }
923
+ end
924
+
925
+ # Add a chart to the current sheet.
926
+ def add_chart(**options, &block)
927
+ add_sheet if @current_sheet.nil?
928
+
929
+ if block_given?
930
+ builder = ChartBuilder.new
931
+ block.call(builder)
932
+ options = builder.options.merge(options)
933
+ end
934
+
935
+ @current_charts << options
936
+ end
937
+
938
+ # --- Hyperlinks ---
939
+
940
+ def add_hyperlink(cell, url = nil, display: nil, tooltip: nil, location: nil)
941
+ add_sheet if @current_sheet.nil?
942
+ link = { cell: cell }
943
+ link[:url] = url if url
944
+ link[:display] = display if display
945
+ link[:tooltip] = tooltip if tooltip
946
+ link[:location] = location if location
947
+ @current_hyperlinks << link
948
+ end
949
+
950
+ # --- Auto Filter / Sort ---
951
+
952
+ # rubocop:disable Naming/AccessorMethodName
953
+ def set_auto_filter(range)
954
+ add_sheet if @current_sheet.nil?
955
+ @current_auto_filter = range
956
+ end
957
+ # rubocop:enable Naming/AccessorMethodName
958
+
959
+ def add_filter_column(col_id, filter)
960
+ add_sheet if @current_sheet.nil?
961
+ @current_filter_columns[col_id] = filter
962
+ end
963
+
964
+ def set_sort_state(ref, sort_conditions, **opts)
965
+ add_sheet if @current_sheet.nil?
966
+ @current_sort_state = { ref: ref, sort_conditions: sort_conditions }.merge(opts)
967
+ end
968
+
969
+ # --- Data Validation ---
970
+
971
+ def add_data_validation(sqref, **opts)
972
+ add_sheet if @current_sheet.nil?
973
+ @current_data_validations << opts.merge(sqref: sqref)
974
+ end
975
+
976
+ # --- Conditional Formatting ---
977
+
978
+ def add_conditional_format(sqref, **opts)
979
+ add_sheet if @current_sheet.nil?
980
+ @current_conditional_formats << opts.merge(sqref: sqref)
981
+ end
982
+
983
+ # --- Tables ---
984
+
985
+ def add_table(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
986
+ add_sheet if @current_sheet.nil?
987
+ tbl = { ref: ref, columns: columns }
988
+ tbl[:name] = name if name
989
+ tbl[:display_name] = display_name if display_name
990
+ tbl[:style] = style if style
991
+ tbl.merge!(opts)
992
+ @current_tables << tbl
993
+ end
994
+
995
+ # --- Pivot Tables ---
996
+
997
+ def add_pivot_table(source_ref, row_fields:, data_fields:, col_fields: [], dest_ref: "E1", name: nil, field_names: nil, items: nil)
998
+ add_sheet if @current_sheet.nil?
999
+ @current_pivot_tables ||= []
1000
+ @current_pivot_tables << {
1001
+ source_ref: source_ref, row_fields: row_fields,
1002
+ data_fields: data_fields, col_fields: col_fields,
1003
+ dest_ref: dest_ref, name: name,
1004
+ field_names: field_names, items: items
1005
+ }
1006
+ end
1007
+
1008
+ # --- Comments ---
1009
+
1010
+ def add_comment(cell, text, author: "Author")
1011
+ add_sheet if @current_sheet.nil?
1012
+ @current_comments << { cell: cell, text: text, author: author }
1013
+ end
1014
+
1015
+ # --- Sparklines ---
1016
+
1017
+ def add_sparkline_group(sparklines:, type: nil, **opts)
1018
+ add_sheet if @current_sheet.nil?
1019
+ group = { sparklines: sparklines }
1020
+ group[:type] = type if type
1021
+ group.merge!(opts)
1022
+ @current_sparkline_groups << group
1023
+ end
1024
+
1025
+ # --- Merge Cells ---
1026
+
1027
+ def merge_cells(range)
1028
+ add_sheet if @current_sheet.nil?
1029
+ @current_merge_cells << range
1030
+ end
1031
+
1032
+ # --- Freeze / Split Panes ---
1033
+
1034
+ def set_freeze_pane(row: 0, col: 0)
1035
+ add_sheet if @current_sheet.nil?
1036
+ @current_freeze_pane = { row: row, col: col }
1037
+ end
1038
+
1039
+ def set_split_pane(x_split: 0, y_split: 0, top_left_cell: nil)
1040
+ add_sheet if @current_sheet.nil?
1041
+ @current_split_pane = { x_split: x_split, y_split: y_split, top_left_cell: top_left_cell }
1042
+ end
1043
+
1044
+ def set_selection(active_cell, sqref: nil, pane: nil)
1045
+ add_sheet if @current_sheet.nil?
1046
+ @current_selection = { active_cell: active_cell, sqref: sqref || active_cell }
1047
+ @current_selection[:pane] = pane if pane
1048
+ end
1049
+
1050
+ # --- Page Setup / Margins / Print ---
1051
+
1052
+ def set_page_margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
1053
+ add_sheet if @current_sheet.nil?
1054
+ @current_page_margins = { left: left, right: right, top: top, bottom: bottom, header: header, footer: footer }.compact
1055
+ end
1056
+
1057
+ def set_page_setup(**opts)
1058
+ add_sheet if @current_sheet.nil?
1059
+ @current_page_setup.merge!(opts)
1060
+ end
1061
+
1062
+ def set_header_footer(**opts)
1063
+ add_sheet if @current_sheet.nil?
1064
+ @current_header_footer.merge!(opts)
1065
+ end
1066
+
1067
+ def set_print_option(name, value)
1068
+ add_sheet if @current_sheet.nil?
1069
+ @current_print_options[name] = value
1070
+ end
1071
+
1072
+ # --- Sheet Protection ---
1073
+
1074
+ def set_sheet_protection(**opts)
1075
+ add_sheet if @current_sheet.nil?
1076
+ normalized = opts.dup
1077
+ plain_password = normalized[:password]
1078
+ needs_hash = plain_password.is_a?(String) && !plain_password.empty? &&
1079
+ normalized[:algorithm_name].nil? && normalized[:hash_value].nil? &&
1080
+ normalized[:salt_value].nil? && normalized[:spin_count].nil? &&
1081
+ !plain_password.match?(/\A[0-9A-Fa-f]{4}\z/)
1082
+ if needs_hash
1083
+ normalized.delete(:password)
1084
+ normalized.merge!(Xlsxrb::Ooxml::Utils.hash_password(plain_password))
1085
+ end
1086
+ @current_sheet_protection = normalized
1087
+ end
1088
+
1089
+ # --- Images ---
1090
+
1091
+ def add_image(file_data, ext: "png", from_col: 0, from_row: 0, to_col: 5, to_row: 10, **opts)
1092
+ add_sheet if @current_sheet.nil?
1093
+ img = { file_data: file_data, ext: ext, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
1094
+ img.merge!(opts)
1095
+ @current_images << img
1096
+ end
1097
+
1098
+ # --- Shapes ---
1099
+
1100
+ def add_shape(preset: "rect", text: nil, from_col: 0, from_row: 0, to_col: 5, to_row: 5, **opts)
1101
+ add_sheet if @current_sheet.nil?
1102
+ shape = { preset: preset, text: text, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
1103
+ shape[:name] = opts.delete(:name) || "Shape #{@current_shapes.size + 1}"
1104
+ shape.merge!(opts)
1105
+ @current_shapes << shape
1106
+ end
1107
+
1108
+ # --- Sheet Properties ---
1109
+
1110
+ def set_sheet_property(name, value)
1111
+ add_sheet if @current_sheet.nil?
1112
+ @current_sheet_properties[name] = value
1113
+ end
1114
+
1115
+ def set_sheet_view(name, value)
1116
+ add_sheet if @current_sheet.nil?
1117
+ @current_sheet_view[name] = value
1118
+ end
1119
+
1120
+ # --- Row / Column Breaks ---
1121
+
1122
+ def add_row_break(row_num)
1123
+ add_sheet if @current_sheet.nil?
1124
+ @current_row_breaks << row_num
1125
+ end
1126
+
1127
+ def add_col_break(col_index)
1128
+ add_sheet if @current_sheet.nil?
1129
+ @current_col_breaks << col_index
1130
+ end
1131
+
1132
+ # --- Workbook-Level Methods ---
1133
+
1134
+ # Add a defined name.
1135
+ def add_defined_name(name, value, sheet: nil, hidden: false)
1136
+ entry = { name: name, value: value, hidden: hidden }
1137
+ if sheet
1138
+ # local_sheet_id will be resolved at close time
1139
+ entry[:local_sheet_name] = sheet
1140
+ end
1141
+ @defined_names << entry
1142
+ end
1143
+
1144
+ # Set the print area for the current or named sheet.
1145
+ def set_print_area(range, sheet: nil)
1146
+ sheet_name = sheet || @current_sheet || "Sheet1"
1147
+ value = "'#{sheet_name}'!#{absolute_range(range)}"
1148
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Area" && dn[:local_sheet_name] == sheet_name }
1149
+ add_defined_name("_xlnm.Print_Area", value, sheet: sheet_name)
1150
+ end
1151
+
1152
+ # Set print titles for the current or named sheet.
1153
+ def set_print_titles(rows: nil, cols: nil, sheet: nil)
1154
+ sheet_name = sheet || @current_sheet || "Sheet1"
1155
+ parts = []
1156
+ parts << "'#{sheet_name}'!$#{cols.sub(":", ":$")}" if cols
1157
+ parts << "'#{sheet_name}'!$#{rows.sub(":", ":$")}" if rows
1158
+ value = parts.join(",")
1159
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Titles" && dn[:local_sheet_name] == sheet_name }
1160
+ add_defined_name("_xlnm.Print_Titles", value, sheet: sheet_name)
1161
+ end
1162
+
1163
+ # Set workbook protection.
1164
+ def set_workbook_protection(**opts)
1165
+ @workbook_protection = opts
1166
+ end
1167
+
1168
+ # Set a core document property.
1169
+ def set_core_property(name, value)
1170
+ @core_properties[name] = value
1171
+ end
1172
+
1173
+ # Set an app document property.
1174
+ def set_app_property(name, value)
1175
+ @app_properties[name] = value
1176
+ end
1177
+
1178
+ # Add a custom document property.
1179
+ def add_custom_property(name, value, type: :string)
1180
+ @custom_properties << { name: name, value: value, type: type }
1181
+ end
1182
+
1183
+ def close
1184
+ TRACER.in_span("StreamWriter#close") do
1185
+ flush_current_sheet
1186
+
1187
+ styles_definition = {
1188
+ fonts: @style_writer.instance_variable_get(:@fonts).dup,
1189
+ fills: @style_writer.instance_variable_get(:@fills).dup,
1190
+ borders: @style_writer.instance_variable_get(:@borders).dup,
1191
+ xf_entries: @style_writer.instance_variable_get(:@xf_entries).dup,
1192
+ num_fmts: @style_writer.instance_variable_get(:@num_fmts).dup
1193
+ }
1194
+
1195
+ resolved_names = resolve_defined_names(@defined_names, @sheets)
1196
+
1197
+ Ooxml::WorkbookWriter.write(
1198
+ @target,
1199
+ sheets: @sheets,
1200
+ shared_strings: @sst,
1201
+ styles: styles_definition,
1202
+ defined_names: resolved_names.empty? ? nil : resolved_names,
1203
+ core_properties: @core_properties.empty? ? nil : @core_properties,
1204
+ app_properties: @app_properties.empty? ? nil : @app_properties,
1205
+ custom_properties: @custom_properties.empty? ? nil : @custom_properties,
1206
+ workbook_protection: @workbook_protection
1207
+ )
1208
+ end
1209
+ ensure
1210
+ @tempfiles.each do |tmp|
1211
+ tmp.close
1212
+ tmp.unlink
1213
+ end
1214
+ end
1215
+
1216
+ private
1217
+
1218
+ def absolute_range(range)
1219
+ range.gsub(/([A-Z]+)(\d+)/, '$\1$\2')
1220
+ end
1221
+
1222
+ def resolve_defined_names(names, sheets)
1223
+ sheet_names = sheets.map { |s| s[:name] }
1224
+ names.map do |dn|
1225
+ resolved = dn.dup
1226
+ if dn[:local_sheet_name]
1227
+ idx = sheet_names.index(dn[:local_sheet_name])
1228
+ resolved[:local_sheet_id] = idx if idx
1229
+ resolved.delete(:local_sheet_name)
1230
+ end
1231
+ resolved
1232
+ end
1233
+ end
1234
+
1235
+ def flush_current_sheet
1236
+ return unless @current_sheet
1237
+
1238
+ @current_tempfile.close
1239
+
1240
+ sheet_data = { name: @current_sheet, rows_tmp_path: @current_tempfile.path, columns: @current_columns }
1241
+ sheet_data[:cells] = @current_cells if @current_cells && !@current_cells.empty?
1242
+ @current_cells = nil
1243
+ sheet_data[:charts] = @current_charts unless @current_charts.empty?
1244
+ sheet_data[:hyperlinks] = @current_hyperlinks unless @current_hyperlinks.empty?
1245
+ sheet_data[:auto_filter] = @current_auto_filter if @current_auto_filter
1246
+ sheet_data[:filter_columns] = @current_filter_columns unless @current_filter_columns.empty?
1247
+ sheet_data[:sort_state] = @current_sort_state if @current_sort_state
1248
+ sheet_data[:data_validations] = @current_data_validations unless @current_data_validations.empty?
1249
+ sheet_data[:conditional_formats] = @current_conditional_formats unless @current_conditional_formats.empty?
1250
+ sheet_data[:tables] = @current_tables unless @current_tables.empty?
1251
+ sheet_data[:pivot_tables] = @current_pivot_tables unless @current_pivot_tables.empty?
1252
+ sheet_data[:sparkline_groups] = @current_sparkline_groups unless @current_sparkline_groups.empty?
1253
+ sheet_data[:comments] = @current_comments unless @current_comments.empty?
1254
+ sheet_data[:merge_cells] = @current_merge_cells unless @current_merge_cells.empty?
1255
+ sheet_data[:freeze_pane] = @current_freeze_pane if @current_freeze_pane
1256
+ sheet_data[:split_pane] = @current_split_pane if @current_split_pane
1257
+ sheet_data[:selection] = @current_selection if @current_selection
1258
+ sheet_data[:page_margins] = @current_page_margins if @current_page_margins
1259
+ sheet_data[:page_setup] = @current_page_setup unless @current_page_setup.empty?
1260
+ sheet_data[:header_footer] = @current_header_footer unless @current_header_footer.empty?
1261
+ sheet_data[:print_options] = @current_print_options unless @current_print_options.empty?
1262
+ sheet_data[:sheet_protection] = @current_sheet_protection if @current_sheet_protection
1263
+ sheet_data[:images] = @current_images unless @current_images.empty?
1264
+ sheet_data[:shapes] = @current_shapes unless @current_shapes.empty?
1265
+ sheet_data[:sheet_properties] = @current_sheet_properties unless @current_sheet_properties.empty?
1266
+ sheet_data[:sheet_view] = @current_sheet_view unless @current_sheet_view.empty?
1267
+ sheet_data[:row_breaks] = @current_row_breaks unless @current_row_breaks.empty?
1268
+ sheet_data[:col_breaks] = @current_col_breaks unless @current_col_breaks.empty?
1269
+ @sheets << sheet_data
1270
+
1271
+ @tempfiles << @current_tempfile
1272
+ @current_sheet = nil
1273
+ @current_tempfile = nil
1274
+ @current_row_writer = nil
1275
+ end
1276
+ end
1277
+
1278
+ class << self
1279
+ private
1280
+
1281
+ def build_worksheet(name, sheet_xml, shared_strings, _styles)
1282
+ return Elements::Worksheet.new(name: name) if sheet_xml.nil? || sheet_xml.empty?
1283
+
1284
+ raw_rows = Ooxml::WorksheetParser.parse(sheet_xml, shared_strings: shared_strings)
1285
+ raw_columns = Ooxml::WorksheetParser.parse_columns(sheet_xml)
1286
+
1287
+ rows = raw_rows.map { |rr| build_row_from_raw(rr) }
1288
+ columns = raw_columns.map do |rc|
1289
+ # Columns from OOXML are 1-based min/max ranges; convert to 0-based
1290
+ Elements::Column.new(
1291
+ index: (rc[:min] || 1) - 1,
1292
+ width: rc[:width],
1293
+ hidden: rc[:hidden] || false,
1294
+ custom_width: rc[:custom_width] || false,
1295
+ outline_level: rc[:outline_level]
1296
+ )
1297
+ end
1298
+
1299
+ Elements::Worksheet.new(name: name, rows: rows, columns: columns)
1300
+ end
1301
+
1302
+ def build_row_from_raw(raw_row)
1303
+ cells = raw_row[:cells].map do |rc|
1304
+ parsed = Elements::Cell.parse_ref(rc[:ref]) if rc[:ref]
1305
+ row_idx = parsed ? parsed[0] : raw_row[:index]
1306
+ col_idx = parsed ? parsed[1] : 0
1307
+
1308
+ cell_errors = Elements::Cell.validate(row_idx, col_idx, rc[:value])
1309
+ if !cell_errors.empty? && rc[:source]
1310
+ cell_errors = cell_errors.map do |err|
1311
+ "#{err} (at #{rc[:source][:part]} row #{rc[:source][:row] + 1} cell #{rc[:ref] || "unknown"})"
1312
+ end
1313
+ end
1314
+
1315
+ Elements::Cell.new(
1316
+ row_index: row_idx,
1317
+ column_index: col_idx,
1318
+ value: rc[:value],
1319
+ formula: rc[:formula],
1320
+ style_index: rc[:style_index],
1321
+ errors: cell_errors.empty? ? nil : cell_errors
1322
+ )
1323
+ end
1324
+ attrs = raw_row[:attrs] || {}
1325
+ row_errors = Elements::Row.validate(raw_row[:index], cells)
1326
+ if !row_errors.empty? && raw_row[:source]
1327
+ row_errors = row_errors.map do |err|
1328
+ "#{err} (at #{raw_row[:source][:part]} row #{raw_row[:source][:row] + 1})"
1329
+ end
1330
+ end
1331
+ Elements::Row.new(
1332
+ index: raw_row[:index],
1333
+ cells: cells,
1334
+ height: attrs[:height],
1335
+ hidden: attrs[:hidden] || false,
1336
+ custom_height: attrs[:custom_height] || false,
1337
+ outline_level: attrs[:outline_level],
1338
+ errors: row_errors.empty? ? nil : row_errors
1339
+ )
1340
+ end
1341
+
1342
+ def build_raw_cell(cell, sst, sst_index)
1343
+ ref = cell.ref
1344
+ value = cell.value
1345
+ result = { ref: ref, style_index: cell.style_index }
1346
+
1347
+ case value
1348
+ when String
1349
+ idx = sst_index[value] ||= begin
1350
+ sst << value
1351
+ sst.size - 1
1352
+ end
1353
+ result[:value] = idx
1354
+ result[:type] = "s"
1355
+ when true, false
1356
+ result[:value] = value
1357
+ result[:type] = "b"
1358
+ when Integer, Float
1359
+ result[:value] = value
1360
+ when Date
1361
+ result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1362
+ when Time
1363
+ result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
1364
+ when NilClass
1365
+ # empty cell
1366
+ end
1367
+
1368
+ if cell.formula
1369
+ f = cell.formula
1370
+ if f.is_a?(Elements::Formula)
1371
+ result[:formula] = f.expression
1372
+ result[:formula_ca] = true if f.calculate_always
1373
+ if f.cached_value
1374
+ # Cached value is written as-is (not through SST)
1375
+ result[:value] = f.cached_value
1376
+ result.delete(:type) # Ensure no type is set; cached values are plain text in <v>
1377
+ end
1378
+ else
1379
+ result[:formula] = f
1380
+ end
1381
+ end
1382
+ result
1383
+ end
1384
+
1385
+ def build_row_attrs(row)
1386
+ attrs = {}
1387
+ attrs[:height] = row.height if row.height
1388
+ attrs[:hidden] = true if row.hidden
1389
+ attrs[:custom_height] = true if row.custom_height
1390
+ attrs[:outline_level] = row.outline_level if row.outline_level
1391
+ attrs
1392
+ end
1393
+ end
1394
+
1395
+ # Builds a raw cell hash from a value for streaming writes.
1396
+ def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index)
1397
+ ref = "#{Elements::Cell.column_letter(col_index)}#{row_index + 1}"
1398
+ result = { ref: ref }
1399
+
1400
+ case value
1401
+ when Elements::Formula
1402
+ result[:formula] = value.expression
1403
+ result[:formula_ca] = true if value.calculate_always
1404
+ result[:value] = value.cached_value if value.cached_value
1405
+ when String
1406
+ idx = sst_index[value] ||= begin
1407
+ sst << value
1408
+ sst.size - 1
1409
+ end
1410
+ result[:value] = idx
1411
+ result[:type] = "s"
1412
+ when true, false
1413
+ result[:value] = value
1414
+ result[:type] = "b"
1415
+ when Integer, Float
1416
+ result[:value] = value
1417
+ when Date
1418
+ result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1419
+ when Time
1420
+ result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
1421
+ when NilClass
1422
+ # empty cell
1423
+ end
1424
+
1425
+ result
1426
+ end
1427
+ end