xlsxrb 0.1.15 → 0.1.17

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -0
  3. data/README.md +24 -5
  4. data/benchmark/micro_ips.rb +70 -0
  5. data/lib/xlsxrb/chart_builder.rb +41 -2
  6. data/lib/xlsxrb/dsl_helpers.rb +522 -6
  7. data/lib/xlsxrb/elements/cell.rb +34 -9
  8. data/lib/xlsxrb/elements/column.rb +2 -2
  9. data/lib/xlsxrb/elements/styles.rb +427 -15
  10. data/lib/xlsxrb/elements/types.rb +1 -0
  11. data/lib/xlsxrb/elements/workbook.rb +79 -4
  12. data/lib/xlsxrb/elements/worksheet.rb +173 -11
  13. data/lib/xlsxrb/ooxml/cfb.rb +10 -0
  14. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +77 -38
  15. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +187 -24
  16. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +115 -2
  17. data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +28 -18
  18. data/lib/xlsxrb/ooxml/reader.rb +253 -136
  19. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +83 -28
  20. data/lib/xlsxrb/ooxml/styles_parser.rb +76 -25
  21. data/lib/xlsxrb/ooxml/utils.rb +59 -2
  22. data/lib/xlsxrb/ooxml/workbook_parser.rb +57 -3
  23. data/lib/xlsxrb/ooxml/workbook_writer.rb +471 -86
  24. data/lib/xlsxrb/ooxml/worksheet_parser.rb +210 -127
  25. data/lib/xlsxrb/ooxml/worksheet_writer.rb +515 -184
  26. data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +595 -453
  27. data/lib/xlsxrb/ooxml/writer/features_xml.rb +26 -9
  28. data/lib/xlsxrb/ooxml/writer/styles_xml.rb +87 -24
  29. data/lib/xlsxrb/ooxml/writer.rb +299 -59
  30. data/lib/xlsxrb/ooxml/xml_builder.rb +12 -4
  31. data/lib/xlsxrb/ooxml/xml_parser.rb +45 -0
  32. data/lib/xlsxrb/ooxml/zip_reader.rb +48 -29
  33. data/lib/xlsxrb/ooxml/zip_writer.rb +15 -5
  34. data/lib/xlsxrb/stream_row.rb +4 -3
  35. data/lib/xlsxrb/stream_sheet.rb +182 -8
  36. data/lib/xlsxrb/stream_writer.rb +977 -84
  37. data/lib/xlsxrb/style_builder.rb +59 -6
  38. data/lib/xlsxrb/utils.rb +80 -0
  39. data/lib/xlsxrb/version.rb +1 -1
  40. data/lib/xlsxrb/workbook_builder.rb +307 -24
  41. data/lib/xlsxrb/worksheet_builder.rb +1150 -49
  42. data/lib/xlsxrb.rb +705 -142
  43. data/sig/generated/xlsxrb/chart_builder.rbs +31 -3
  44. data/sig/generated/xlsxrb/dsl_helpers.rbs +119 -4
  45. data/sig/generated/xlsxrb/elements/cell.rbs +20 -3
  46. data/sig/generated/xlsxrb/elements/column.rbs +3 -3
  47. data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
  48. data/sig/generated/xlsxrb/elements/types.rbs +2 -0
  49. data/sig/generated/xlsxrb/elements/workbook.rbs +63 -3
  50. data/sig/generated/xlsxrb/elements/worksheet.rbs +92 -2
  51. data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +2 -0
  52. data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +74 -2
  53. data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +36 -0
  54. data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +5 -0
  55. data/sig/generated/xlsxrb/ooxml/reader.rbs +29 -0
  56. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +3 -1
  57. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -0
  58. data/sig/generated/xlsxrb/ooxml/utils.rbs +22 -0
  59. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +15 -0
  60. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +20 -4
  61. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +19 -3
  62. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +15 -2
  63. data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +16 -0
  64. data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
  65. data/sig/generated/xlsxrb/ooxml/writer.rbs +42 -8
  66. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +4 -1
  67. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +6 -6
  68. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +8 -2
  69. data/sig/generated/xlsxrb/stream_row.rbs +3 -2
  70. data/sig/generated/xlsxrb/stream_sheet.rbs +122 -6
  71. data/sig/generated/xlsxrb/stream_writer.rbs +606 -30
  72. data/sig/generated/xlsxrb/style_builder.rbs +24 -6
  73. data/sig/generated/xlsxrb/utils.rbs +33 -0
  74. data/sig/generated/xlsxrb/workbook_builder.rbs +87 -5
  75. data/sig/generated/xlsxrb/worksheet_builder.rbs +557 -12
  76. data/sig/generated/xlsxrb.rbs +118 -20
  77. metadata +2 -1
@@ -32,19 +32,22 @@ module Xlsxrb
32
32
 
33
33
  def self.write(target, sheets:, shared_strings: [], shared_strings_index: nil, styles: nil,
34
34
  defined_names: nil, core_properties: nil, app_properties: nil,
35
- custom_properties: nil, workbook_protection: nil, workbook_properties: nil)
35
+ custom_properties: nil, workbook_protection: nil, workbook_properties: nil,
36
+ compression_level: Zlib::DEFAULT_COMPRESSION)
36
37
  writer = new(sheets: sheets, shared_strings: shared_strings, shared_strings_index: shared_strings_index, styles: styles,
37
38
  defined_names: defined_names, core_properties: core_properties,
38
39
  app_properties: app_properties, custom_properties: custom_properties,
39
- workbook_protection: workbook_protection, workbook_properties: workbook_properties)
40
+ workbook_protection: workbook_protection, workbook_properties: workbook_properties,
41
+ compression_level: compression_level)
40
42
  writer.write_to(target)
41
43
  end
42
44
 
43
45
  def initialize(sheets:, shared_strings: [], shared_strings_index: nil, styles: nil,
44
46
  defined_names: nil, core_properties: nil, app_properties: nil,
45
- custom_properties: nil, workbook_protection: nil, workbook_properties: nil)
47
+ custom_properties: nil, workbook_protection: nil, workbook_properties: nil,
48
+ compression_level: Zlib::DEFAULT_COMPRESSION)
46
49
  @sheets = sheets
47
- @shared_strings = shared_strings
50
+ @shared_strings = shared_strings || []
48
51
  @shared_strings_index = shared_strings_index
49
52
  @styles = styles || {}
50
53
  @defined_names = defined_names || []
@@ -53,27 +56,42 @@ module Xlsxrb
53
56
  @custom_properties = custom_properties || []
54
57
  @workbook_protection = workbook_protection
55
58
  @workbook_properties = workbook_properties || { update_links: "never" }
59
+ @compression_level = compression_level || Zlib::DEFAULT_COMPRESSION
56
60
  @drawing_count = 0
57
61
  @chart_count = 0
58
62
  @comment_count = 0
59
63
  @table_count = 0
60
64
  @pivot_cache_count = 0
61
65
  @pivot_table_count = 0
66
+ @threaded_comment_count = 0
67
+ @persons = []
62
68
  end
63
69
 
64
70
  def write_to(target)
65
71
  preprocess_conditional_formats!
66
72
 
67
- ZipWriter.open(target) do |zip|
73
+ active_tab_idx = resolve_active_tab_index
74
+ effective_active_idx = active_tab_idx || 0
75
+
76
+ ZipWriter.open(target, compression_level: @compression_level) do |zip|
68
77
  @sheets.each_with_index do |sheet, idx|
69
78
  info = prepare_sheet_auxiliary(sheet, idx)
70
79
  sheet_tables = sheet[:tables] || []
71
80
 
81
+ sv = sheet[:sheet_view]
82
+ if idx == effective_active_idx
83
+ sv = (sv || {}).merge(tab_selected: true)
84
+ elsif sv&.key?(:tab_selected)
85
+ sv = sv.dup
86
+ sv.delete(:tab_selected)
87
+ end
88
+
72
89
  zip.start_entry("xl/worksheets/sheet#{idx + 1}.xml")
73
90
  write_worksheet_xml(
74
91
  ZipEntryIO.new(zip),
75
92
  sheet,
76
93
  drawing_rid: info[:drawing_rid],
94
+ picture_rid: info[:picture_rid],
77
95
  sheet_protection: sheet[:sheet_protection],
78
96
  auto_filter: sheet[:auto_filter],
79
97
  filter_columns: sheet[:filter_columns],
@@ -91,11 +109,14 @@ module Xlsxrb
91
109
  freeze_pane: sheet[:freeze_pane],
92
110
  split_pane: sheet[:split_pane],
93
111
  selection: sheet[:selection],
94
- sheet_view: sheet[:sheet_view],
112
+ sheet_view: sv,
95
113
  sheet_properties: sheet[:sheet_properties],
96
114
  sheet_format: sheet[:sheet_format],
97
115
  protected_ranges: sheet[:protected_ranges],
98
116
  cell_watches: sheet[:cell_watches],
117
+ ignored_errors: sheet[:ignored_errors],
118
+ phonetic_pr: sheet[:phonetic_properties] || sheet[:phonetic_pr],
119
+ sheet_calc_pr: sheet[:sheet_calc_pr],
99
120
  tables: sheet_tables,
100
121
  table_start_rid: info[:table_start_rid],
101
122
  legacy_drawing_rid: info[:vml_rid],
@@ -108,20 +129,23 @@ module Xlsxrb
108
129
  end
109
130
  end
110
131
 
111
- def prepare_sheet_auxiliary(sheet, _idx)
132
+ def prepare_sheet_auxiliary(sheet, idx)
112
133
  sheet_images = sheet[:images] || []
113
134
  sheet_charts = sheet[:charts] || []
114
135
  sheet_shapes = sheet[:shapes] || []
115
136
  sheet_comments = sheet[:comments] || []
137
+ sheet_threaded_comments = sheet[:threaded_comments] || []
116
138
  sheet_tables = sheet[:tables] || []
117
139
  sheet_hyperlinks = sheet[:hyperlinks] || []
118
140
  has_drawing = sheet_images.any? || sheet_charts.any? || sheet_shapes.any?
119
141
  has_comments = sheet_comments.any?
142
+ has_threaded_comments = sheet_threaded_comments.any?
120
143
 
121
144
  sheet_rels = []
122
145
  drawing_rid = nil
123
146
  vml_rid = nil
124
147
  table_start_rid = nil
148
+ picture_rid = nil
125
149
  hyperlink_rels = []
126
150
 
127
151
  if has_drawing
@@ -140,6 +164,12 @@ module Xlsxrb
140
164
  vml_rid = "rId#{vml_rid_num}"
141
165
  end
142
166
 
167
+ if has_threaded_comments
168
+ @threaded_comment_count += 1
169
+ tc_rid_num = sheet_rels.size + 1
170
+ sheet_rels << { id: "rId#{tc_rid_num}", type: "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment", target: "../threadedComments/threadedComment#{@threaded_comment_count}.xml" }
171
+ end
172
+
143
173
  sheet_hyperlinks.each do |link|
144
174
  next unless link[:url]
145
175
 
@@ -178,6 +208,33 @@ module Xlsxrb
178
208
  end
179
209
  end
180
210
 
211
+ bg_img = sheet[:background_image] || sheet[:background_picture] || sheet[:picture]
212
+ if bg_img
213
+ bg_data = if bg_img.is_a?(Hash)
214
+ bg_img[:file_data] || bg_img[:data]
215
+ else
216
+ (bg_img.respond_to?(:data) ? bg_img.data : bg_img)
217
+ end
218
+ if bg_data.is_a?(String) && !bg_data.include?("\x00") && File.file?(bg_data)
219
+ bg_ext = File.extname(bg_data).delete_prefix(".").downcase
220
+ bg_data = File.binread(bg_data)
221
+ else
222
+ bg_ext = if bg_img.is_a?(Hash)
223
+ (bg_img[:ext] || (bg_img[:filename] && File.extname(bg_img[:filename]).delete_prefix(".")) || "png").to_s.downcase
224
+ elsif bg_img.respond_to?(:filename)
225
+ File.extname(bg_img.filename).delete_prefix(".").downcase
226
+ else
227
+ "png"
228
+ end
229
+ end
230
+ if bg_data && !bg_data.empty?
231
+ p_rid_num = sheet_rels.size + 1
232
+ picture_rid = "rId#{p_rid_num}"
233
+ sheet_rels << { id: picture_rid, type: "#{DOC_REL}/image", target: "../media/sheet#{idx + 1}_bg.#{bg_ext}" }
234
+ sheet[:_bg_media] = { path: "xl/media/sheet#{idx + 1}_bg.#{bg_ext}", data: bg_data.b, ext: bg_ext }
235
+ end
236
+ end
237
+
181
238
  sheet[:_prepared_rels] = sheet_rels
182
239
  sheet[:_enriched_hyperlinks] = enriched_hyperlinks
183
240
 
@@ -185,6 +242,7 @@ module Xlsxrb
185
242
  drawing_rid: drawing_rid,
186
243
  vml_rid: vml_rid,
187
244
  table_start_rid: table_start_rid,
245
+ picture_rid: picture_rid,
188
246
  enriched_hyperlinks: enriched_hyperlinks,
189
247
  sheet_rels: sheet_rels
190
248
  }
@@ -193,6 +251,7 @@ module Xlsxrb
193
251
  def write_auxiliary_parts(zip)
194
252
  drawing_idx = 0
195
253
  comment_idx = 0
254
+ tc_idx = 0
196
255
  table_idx = 0
197
256
  pivot_cache_idx = 0
198
257
  pivot_table_idx = 0
@@ -268,6 +327,29 @@ module Xlsxrb
268
327
  zip.add_entry("xl/drawings/vmlDrawing#{comment_idx}.vml", comment_writer.send(:generate_vml_drawing_xml, comment_writer.comments))
269
328
  end
270
329
 
330
+ sheet_threaded_comments = sheet[:threaded_comments] || []
331
+ if sheet_threaded_comments.any?
332
+ tc_idx += 1
333
+ tc_writer = Xlsxrb::Ooxml::Writer.new
334
+ sheet_threaded_comments.each do |c|
335
+ tc_writer.add_threaded_comment(
336
+ c[:cell] || c[:ref],
337
+ c[:text],
338
+ author: c[:author] || "Author",
339
+ timestamp: c[:timestamp] || c[:dt],
340
+ id: c[:id],
341
+ parent_id: c[:parent_id],
342
+ done: c[:done],
343
+ user_id: c[:user_id],
344
+ provider_id: c[:provider_id]
345
+ )
346
+ end
347
+ tc_writer.persons.each do |p|
348
+ @persons << p unless @persons.any? { |ep| ep[:id] == p[:id] || ep[:display_name] == p[:display_name] }
349
+ end
350
+ zip.add_entry("xl/threadedComments/threadedComment#{tc_idx}.xml", tc_writer.send(:generate_threaded_comments_xml, tc_writer.threaded_comments))
351
+ end
352
+
271
353
  sheet_tables.each do |tbl|
272
354
  table_idx += 1
273
355
  zip.add_entry("xl/tables/table#{table_idx}.xml", build_table_xml(tbl, table_idx))
@@ -308,8 +390,15 @@ module Xlsxrb
308
390
  end
309
391
  end
310
392
 
393
+ zip.add_binary_entry(sheet[:_bg_media][:path], sheet[:_bg_media][:data]) if sheet[:_bg_media]
394
+
311
395
  zip.add_entry("xl/worksheets/_rels/sheet#{idx + 1}.xml.rels", build_sheet_rels_from_list(sheet_rels)) unless sheet_rels.empty?
312
396
  end
397
+
398
+ return unless @persons.any?
399
+
400
+ p_writer = Xlsxrb::Ooxml::Writer.new
401
+ zip.add_entry("xl/persons/person.xml", p_writer.send(:generate_persons_xml, @persons))
313
402
  end
314
403
 
315
404
  def write_package_metadata(zip)
@@ -407,6 +496,23 @@ module Xlsxrb
407
496
  end
408
497
  end
409
498
 
499
+ if sheet[:_bg_media]
500
+ bg_ext = sheet[:_bg_media][:ext]
501
+ unless image_exts[bg_ext]
502
+ mime = case bg_ext
503
+ when "png" then "image/png"
504
+ when "jpg", "jpeg" then "image/jpeg"
505
+ when "gif" then "image/gif"
506
+ when "bmp" then "image/bmp"
507
+ when "emf" then "image/x-emf"
508
+ when "wmf" then "image/x-wmf"
509
+ else "application/octet-stream"
510
+ end
511
+ b.empty_tag("Default", { Extension: bg_ext, ContentType: mime })
512
+ image_exts[bg_ext] = true
513
+ end
514
+ end
515
+
410
516
  if sheet_comments.any?
411
517
  comment_count += 1
412
518
  b.empty_tag("Override", { PartName: "/xl/comments#{comment_count}.xml", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" })
@@ -427,6 +533,11 @@ module Xlsxrb
427
533
  end
428
534
  end
429
535
 
536
+ b.empty_tag("Override", { PartName: "/xl/persons/person.xml", ContentType: "application/vnd.ms-excel.person+xml" }) if @persons.any?
537
+ (1..@threaded_comment_count).each do |tc|
538
+ b.empty_tag("Override", { PartName: "/xl/threadedComments/threadedComment#{tc}.xml", ContentType: "application/vnd.ms-excel.threadedcomments+xml" })
539
+ end
540
+
430
541
  b.close_tag("Types")
431
542
  io.string
432
543
  end
@@ -484,6 +595,7 @@ module Xlsxrb
484
595
  attrs = {}
485
596
  attrs[:date1904] = "1" if @workbook_properties[:date1904]
486
597
  attrs[:updateLinks] = @workbook_properties[:update_links] if @workbook_properties[:update_links]
598
+ attrs[:codeName] = @workbook_properties[:code_name] if @workbook_properties[:code_name]
487
599
  b.empty_tag("workbookPr", attrs) unless attrs.empty?
488
600
  end
489
601
 
@@ -496,6 +608,26 @@ module Xlsxrb
496
608
  b.empty_tag("workbookProtection", wp_attrs) unless wp_attrs.empty?
497
609
  end
498
610
 
611
+ # Workbook view: activeTab, firstSheet, showSheetTabs, tabRatio
612
+ active_tab_idx = resolve_active_tab_index
613
+ first_sheet_idx = resolve_first_sheet_index
614
+ show_sheet_tabs = @workbook_properties&.[](:show_sheet_tabs)
615
+ tab_ratio = @workbook_properties&.[](:tab_ratio)
616
+
617
+ emit_active_tab = !@workbook_properties&.[](:active_tab).nil? || active_tab_idx&.positive?
618
+
619
+ has_book_views = emit_active_tab || first_sheet_idx || !show_sheet_tabs.nil? || tab_ratio
620
+ if has_book_views
621
+ bv_attrs = {}
622
+ bv_attrs[:activeTab] = active_tab_idx.to_s if emit_active_tab
623
+ bv_attrs[:firstSheet] = first_sheet_idx.to_s if first_sheet_idx
624
+ bv_attrs[:showSheetTabs] = show_sheet_tabs ? "1" : "0" unless show_sheet_tabs.nil?
625
+ bv_attrs[:tabRatio] = tab_ratio.to_s if tab_ratio
626
+ b.open_tag("bookViews")
627
+ b.empty_tag("workbookView", bv_attrs)
628
+ b.close_tag("bookViews")
629
+ end
630
+
499
631
  b.open_tag("sheets")
500
632
  @sheets.each_with_index do |sheet, idx|
501
633
  s_name = sheet.respond_to?(:name) ? sheet.name : sheet[:name]
@@ -531,6 +663,9 @@ module Xlsxrb
531
663
  b.close_tag("definedNames")
532
664
  end
533
665
 
666
+ # Calculation properties: ask Excel to recalculate every formula when the file is opened
667
+ b.empty_tag("calcPr", { fullCalcOnLoad: "1" }) if @workbook_properties&.[](:full_calc_on_load)
668
+
534
669
  # Pivot caches
535
670
  total_pivots = @sheets.sum { |s| (s[:pivot_tables] || []).size }
536
671
  if total_pivots.positive?
@@ -586,6 +721,15 @@ module Xlsxrb
586
721
  })
587
722
  end
588
723
 
724
+ if @persons.any?
725
+ rid += 1
726
+ b.empty_tag("Relationship", {
727
+ Id: "rId#{rid}",
728
+ Type: "http://schemas.microsoft.com/office/2017/10/relationships/person",
729
+ Target: "persons/person.xml"
730
+ })
731
+ end
732
+
589
733
  b.close_tag("Relationships")
590
734
  io.string
591
735
  end
@@ -597,7 +741,11 @@ module Xlsxrb
597
741
  b.open_tag("styleSheet", { xmlns: SSML_NS })
598
742
 
599
743
  # Number formats
600
- raw_num_fmts = @styles&.dig(:num_fmts) || []
744
+ raw_num_fmts = if @styles.respond_to?(:num_fmts)
745
+ @styles.num_fmts
746
+ else
747
+ @styles&.dig(:num_fmts) || []
748
+ end
601
749
  num_fmts = if raw_num_fmts.is_a?(Hash)
602
750
  raw_num_fmts.map { |k, v| { num_fmt_id: k.to_i, format_code: v.to_s } }
603
751
  elsif raw_num_fmts.is_a?(Array)
@@ -622,7 +770,11 @@ module Xlsxrb
622
770
  end
623
771
 
624
772
  # Fonts
625
- fonts = @styles&.dig(:fonts) || []
773
+ fonts = if @styles.respond_to?(:fonts)
774
+ @styles.fonts
775
+ else
776
+ @styles&.dig(:fonts) || []
777
+ end
626
778
  fonts = [{}] if fonts.empty? # At least one default font
627
779
  b.tag("fonts", { count: fonts.size.to_s }) do |_|
628
780
  fonts.each do |font_props|
@@ -631,17 +783,38 @@ module Xlsxrb
631
783
  b.empty_tag("i") if font_props[:italic]
632
784
  b.empty_tag("strike") if font_props[:strike]
633
785
  if font_props[:underline]
634
- b.empty_tag("u", font_props[:underline] == "single" ? {} : { val: font_props[:underline] })
786
+ u_val = font_props[:underline]
787
+ if u_val == true || u_val.to_s == "single"
788
+ b.empty_tag("u")
789
+ else
790
+ mapped_u = StyleBuilder::UNDERLINE_MAP[u_val.to_sym] || u_val.to_s
791
+ b.empty_tag("u", { val: mapped_u })
792
+ end
793
+ end
794
+ va_val = font_props[:vert_align] || font_props[:vertical_align]
795
+ va_val ||= "superscript" if font_props[:superscript]
796
+ va_val ||= "subscript" if font_props[:subscript]
797
+ if va_val
798
+ mapped_va = StyleBuilder::VERT_ALIGN_MAP[va_val.to_sym] || va_val.to_s
799
+ b.empty_tag("vertAlign", { val: mapped_va })
635
800
  end
636
801
  b.empty_tag("sz", { val: (font_props[:sz] || 11).to_s }) if font_props[:sz] || fonts == [{}]
637
- b.empty_tag("color", { rgb: font_props[:color] }) if font_props[:color]
802
+ color_attrs = color_tag_attrs(font_props[:color] || (font_props.key?(:rgb) || font_props.key?(:theme) || font_props.key?(:indexed) ? font_props : nil))
803
+ b.empty_tag("color", color_attrs) if color_attrs
638
804
  b.empty_tag("name", { val: font_props[:name] || "Calibri" })
805
+ b.empty_tag("family", { val: font_props[:family].to_s }) if font_props[:family]
806
+ b.empty_tag("charset", { val: font_props[:charset].to_s }) if font_props[:charset]
807
+ b.empty_tag("scheme", { val: font_props[:scheme].to_s }) if font_props[:scheme]
639
808
  end
640
809
  end
641
810
  end
642
811
 
643
812
  # Fills
644
- fills = @styles&.dig(:fills) || []
813
+ fills = if @styles.respond_to?(:fills)
814
+ @styles.fills
815
+ else
816
+ @styles&.dig(:fills) || []
817
+ end
645
818
  fills = [{ pattern: "none" }, { pattern: "gray125" }] if fills.empty?
646
819
  b.tag("fills", { count: fills.size.to_s }) do |_|
647
820
  fills.each do |fill_props|
@@ -650,14 +823,18 @@ module Xlsxrb
650
823
  b.open_tag("gradientFill", { type: fill_props[:gradient][:type], degree: fill_props[:gradient][:degree]&.to_s }.compact)
651
824
  fill_props[:gradient][:stops].each do |stop|
652
825
  b.tag("stop", { position: stop[:position].to_s }) do |_|
653
- b.empty_tag("color", { rgb: stop[:color] })
826
+ c_attrs = color_tag_attrs(stop[:color] || stop)
827
+ b.empty_tag("color", c_attrs) if c_attrs
654
828
  end
655
829
  end
656
830
  b.close_tag("gradientFill")
657
831
  else
658
832
  b.tag("patternFill", { patternType: fill_props[:pattern] || "none" }) do |_|
659
- b.empty_tag("fgColor", { rgb: fill_props[:fg_color] }) if fill_props[:fg_color]
660
- b.empty_tag("bgColor", { rgb: fill_props[:bg_color] }) if fill_props[:bg_color]
833
+ fg_attrs = color_tag_attrs(fill_props[:fg_color] || (fill_props[:fg_color_theme] ? { theme: fill_props[:fg_color_theme], tint: fill_props[:fg_color_tint] } : nil) || (fill_props[:fg_color_indexed] ? { indexed: fill_props[:fg_color_indexed] } : nil))
834
+ b.empty_tag("fgColor", fg_attrs) if fg_attrs
835
+
836
+ bg_attrs = color_tag_attrs(fill_props[:bg_color] || (fill_props[:bg_color_theme] ? { theme: fill_props[:bg_color_theme], tint: fill_props[:bg_color_tint] } : nil) || (fill_props[:bg_color_indexed] ? { indexed: fill_props[:bg_color_indexed] } : nil))
837
+ b.empty_tag("bgColor", bg_attrs) if bg_attrs
661
838
  end
662
839
  end
663
840
  end
@@ -665,7 +842,11 @@ module Xlsxrb
665
842
  end
666
843
 
667
844
  # Borders
668
- borders = @styles&.dig(:borders) || []
845
+ borders = if @styles.respond_to?(:borders)
846
+ @styles.borders
847
+ else
848
+ @styles&.dig(:borders) || []
849
+ end
669
850
  borders = [{}] if borders.empty?
670
851
  b.tag("borders", { count: borders.size.to_s }) do |_|
671
852
  borders.each do |border_props|
@@ -677,16 +858,8 @@ module Xlsxrb
677
858
  side_data = border_props[side]
678
859
  if side_data
679
860
  b.tag(side.to_s, { style: side_data[:style] }) do |_|
680
- c = side_data[:color]
681
- if c.is_a?(Hash)
682
- c_attrs = {}
683
- c_attrs[:rgb] = c[:rgb] if c[:rgb]
684
- c_attrs[:theme] = c[:theme].to_s if c[:theme]
685
- c_attrs[:tint] = c[:tint].to_s if c[:tint]
686
- b.empty_tag("color", c_attrs) unless c_attrs.empty?
687
- elsif c
688
- b.empty_tag("color", { rgb: c })
689
- end
861
+ c_attrs = color_tag_attrs(side_data[:color])
862
+ b.empty_tag("color", c_attrs) if c_attrs
690
863
  end
691
864
  else
692
865
  b.empty_tag(side.to_s)
@@ -702,7 +875,11 @@ module Xlsxrb
702
875
  end
703
876
 
704
877
  # Cell XFs (formatting)
705
- xf_entries = @styles&.dig(:cell_xfs) || @styles&.dig(:xf_entries) || []
878
+ xf_entries = if @styles.respond_to?(:cell_xfs)
879
+ @styles.cell_xfs
880
+ else
881
+ @styles&.dig(:cell_xfs) || @styles&.dig(:xf_entries) || []
882
+ end
706
883
  xf_count = [xf_entries.size, 1].max
707
884
  b.tag("cellXfs", { count: xf_count.to_s }) do |_|
708
885
  xf_entries.each do |xf|
@@ -751,7 +928,11 @@ module Xlsxrb
751
928
  end
752
929
 
753
930
  # Differential formats (for conditional formatting)
754
- dxfs = @styles&.dig(:dxfs) || []
931
+ dxfs = if @styles.respond_to?(:dxfs)
932
+ @styles.dxfs
933
+ else
934
+ @styles&.dig(:dxfs) || []
935
+ end
755
936
  unless dxfs.empty?
756
937
  b.tag("dxfs", { count: dxfs.size.to_s }) do |_|
757
938
  dxfs.each do |dxf|
@@ -762,6 +943,9 @@ module Xlsxrb
762
943
  b.empty_tag("numFmt", { numFmtId: nf[:num_fmt_id].to_s, formatCode: nf[:format_code].to_s })
763
944
  end
764
945
  write_dxf_fill(b, dxf[:fill]) if dxf[:fill]
946
+ write_dxf_alignment(b, dxf[:alignment]) if dxf[:alignment]
947
+ write_dxf_border(b, dxf[:border]) if dxf[:border]
948
+ write_dxf_protection(b, dxf[:protection]) if dxf[:protection]
765
949
  end
766
950
  end
767
951
  end
@@ -772,8 +956,6 @@ module Xlsxrb
772
956
  end
773
957
 
774
958
  def preprocess_conditional_formats!
775
- @styles[:dxfs] ||= []
776
-
777
959
  @sheets.each do |sheet|
778
960
  rules = sheet[:conditional_formats]
779
961
  next if rules.nil? || rules.empty?
@@ -785,11 +967,15 @@ module Xlsxrb
785
967
  dxf = normalized.delete(:dxf) || extract_dxf_from_rule!(normalized)
786
968
  next normalized unless dxf
787
969
 
788
- dxf_id = @styles[:dxfs].index(dxf)
789
- unless dxf_id
790
- @styles[:dxfs] << dxf
791
- dxf_id = @styles[:dxfs].size - 1
792
- end
970
+ dxf_id = if @styles.respond_to?(:add_dxf)
971
+ @styles.dxfs.index(dxf) || @styles.add_dxf(dxf)
972
+ else
973
+ @styles[:dxfs] ||= []
974
+ @styles[:dxfs].index(dxf) || begin
975
+ @styles[:dxfs] << dxf
976
+ @styles[:dxfs].size - 1
977
+ end
978
+ end
793
979
  normalized[:format_id] = dxf_id
794
980
  normalized
795
981
  end
@@ -797,48 +983,162 @@ module Xlsxrb
797
983
  end
798
984
 
799
985
  def extract_dxf_from_rule!(rule)
800
- font = {}
801
- font[:color] = rule.delete(:font_color) if rule.key?(:font_color)
802
- font[:bold] = rule.delete(:bold) if rule.key?(:bold)
803
- font[:italic] = rule.delete(:italic) if rule.key?(:italic)
804
- font[:underline] = rule.delete(:underline) if rule.key?(:underline)
805
-
806
- fill = {}
807
- if rule.key?(:fill_color)
808
- fill[:pattern] = "solid"
809
- fill[:fg_color] = rule.delete(:fill_color)
810
- end
811
-
812
- dxf = {}
813
- dxf[:font] = font unless font.empty?
814
- dxf[:fill] = fill unless fill.empty?
815
- dxf.empty? ? nil : dxf
986
+ DslHelpers.extract_dxf_from_rule!(rule)
816
987
  end
817
988
 
818
989
  def write_dxf_font(builder, font)
819
990
  builder.tag("font") do |_|
820
- builder.empty_tag("b") if font[:bold]
821
- builder.empty_tag("i") if font[:italic]
991
+ if font.key?(:bold)
992
+ font[:bold] ? builder.empty_tag("b") : builder.empty_tag("b", { val: "0" })
993
+ end
994
+ if font.key?(:italic)
995
+ font[:italic] ? builder.empty_tag("i") : builder.empty_tag("i", { val: "0" })
996
+ end
997
+ if font.key?(:strike)
998
+ font[:strike] ? builder.empty_tag("strike") : builder.empty_tag("strike", { val: "0" })
999
+ end
822
1000
  if font[:underline]
823
- if font[:underline] == true
1001
+ if [true, "single"].include?(font[:underline])
824
1002
  builder.empty_tag("u")
825
1003
  else
826
- builder.empty_tag("u", { val: font[:underline] })
1004
+ mapped_u = StyleBuilder::UNDERLINE_MAP[font[:underline].to_sym] || font[:underline]
1005
+ builder.empty_tag("u", { val: mapped_u })
827
1006
  end
828
1007
  end
829
- builder.empty_tag("color", { rgb: font[:color] }) if font[:color]
1008
+ va_val = font[:vert_align] || font[:vertical_align]
1009
+ va_val ||= "superscript" if font[:superscript]
1010
+ va_val ||= "subscript" if font[:subscript]
1011
+ if va_val
1012
+ mapped_va = StyleBuilder::VERT_ALIGN_MAP[va_val.to_sym] || va_val.to_s
1013
+ builder.empty_tag("vertAlign", { val: mapped_va })
1014
+ end
1015
+ color_attrs = color_tag_attrs(font[:color] || font)
1016
+ builder.empty_tag("color", color_attrs) if color_attrs
830
1017
  builder.empty_tag("name", { val: font[:name] }) if font[:name]
831
- builder.empty_tag("sz", { val: font[:sz].to_s }) if font[:sz]
1018
+ sz_val = font[:sz] || font[:size]
1019
+ builder.empty_tag("sz", { val: sz_val.to_s }) if sz_val
832
1020
  end
833
1021
  end
834
1022
 
835
1023
  def write_dxf_fill(builder, fill)
836
1024
  builder.tag("fill") do |_|
837
- pattern = fill[:pattern] || "solid"
838
- builder.tag("patternFill", { patternType: pattern }) do |_|
839
- builder.empty_tag("fgColor", { rgb: fill[:fg_color] }) if fill[:fg_color]
840
- builder.empty_tag("bgColor", { rgb: fill[:bg_color] }) if fill[:bg_color]
1025
+ pattern_attrs = {}
1026
+ pattern = fill[:pattern] || fill[:pattern_type]
1027
+ pattern ||= "solid" if (fill[:color] || fill[:fill_color] || fill[:fg_color]) && !fill[:bg_color]
1028
+ pattern_attrs[:patternType] = pattern if pattern
1029
+ builder.tag("patternFill", pattern_attrs) do |_|
1030
+ fg = fill[:fg_color] || fill[:color] || fill[:fill_color]
1031
+ fg_attrs = color_tag_attrs(fg)
1032
+ builder.empty_tag("fgColor", fg_attrs) if fg_attrs
1033
+ bg_attrs = color_tag_attrs(fill[:bg_color])
1034
+ builder.empty_tag("bgColor", bg_attrs) if bg_attrs
1035
+ end
1036
+ end
1037
+ end
1038
+
1039
+ def write_dxf_alignment(builder, alignment)
1040
+ return if alignment.nil? || alignment.empty?
1041
+
1042
+ align_attrs = {}
1043
+ align_attrs[:horizontal] = alignment[:horizontal] if alignment[:horizontal]
1044
+ align_attrs[:vertical] = alignment[:vertical] if alignment[:vertical]
1045
+ align_attrs[:wrapText] = "1" if alignment[:wrap_text]
1046
+ align_attrs[:textRotation] = alignment[:text_rotation].to_s if alignment[:text_rotation]
1047
+ align_attrs[:indent] = alignment[:indent].to_s if alignment[:indent]
1048
+ align_attrs[:relativeIndent] = alignment[:relative_indent].to_s if alignment[:relative_indent]
1049
+ align_attrs[:shrinkToFit] = "1" if alignment[:shrink_to_fit]
1050
+ align_attrs[:readingOrder] = alignment[:reading_order].to_s if alignment[:reading_order]
1051
+ align_attrs[:justifyLastLine] = "1" if alignment[:justify_last_line]
1052
+ builder.empty_tag("alignment", align_attrs) unless align_attrs.empty?
1053
+ end
1054
+
1055
+ def write_dxf_border(builder, border)
1056
+ return if border.nil? || border.empty?
1057
+
1058
+ border_attrs = {}
1059
+ border_attrs[:diagonalUp] = "1" if border[:diagonal_up]
1060
+ border_attrs[:diagonalDown] = "1" if border[:diagonal_down]
1061
+
1062
+ builder.tag("border", border_attrs) do |_|
1063
+ %i[left right top bottom diagonal].each do |side|
1064
+ side_data = border[side]
1065
+ next unless side_data
1066
+
1067
+ tag_attrs = {}
1068
+ tag_attrs[:style] = side_data[:style] if side_data[:style]
1069
+ c_attrs = color_tag_attrs(side_data[:color])
1070
+ if c_attrs
1071
+ builder.tag(side.to_s, tag_attrs) do |_|
1072
+ builder.empty_tag("color", c_attrs)
1073
+ end
1074
+ elsif !tag_attrs.empty?
1075
+ builder.empty_tag(side.to_s, tag_attrs)
1076
+ else
1077
+ builder.empty_tag(side.to_s)
1078
+ end
1079
+ end
1080
+ end
1081
+ end
1082
+
1083
+ def write_dxf_protection(builder, protection)
1084
+ return if protection.nil? || protection.empty?
1085
+
1086
+ prot_attrs = {}
1087
+ prot_attrs[:locked] = protection[:locked] ? "1" : "0" unless protection[:locked].nil?
1088
+ prot_attrs[:hidden] = protection[:hidden] ? "1" : "0" unless protection[:hidden].nil?
1089
+ builder.empty_tag("protection", prot_attrs) unless prot_attrs.empty?
1090
+ end
1091
+
1092
+ # : (untyped rgb) -> ::String?
1093
+ def format_rgb_hex(rgb)
1094
+ return nil unless rgb
1095
+
1096
+ str = rgb.to_s.sub(/^#/, "").strip
1097
+ return nil if str.empty?
1098
+
1099
+ case str
1100
+ when /\A[0-9a-fA-F]{8}\z/
1101
+ str.upcase
1102
+ when /\A[0-9a-fA-F]{6}\z/
1103
+ "FF#{str.upcase}"
1104
+ when /\A[0-9a-fA-F]{3}\z/
1105
+ r, g, b = str.chars
1106
+ "FF#{r}#{r}#{g}#{g}#{b}#{b}".upcase
1107
+ else
1108
+ resolved = StyleBuilder.resolve_color(rgb)
1109
+ case resolved
1110
+ when /\A[0-9a-fA-F]{8}\z/
1111
+ resolved.upcase
1112
+ when /\A[0-9a-fA-F]{6}\z/
1113
+ "FF#{resolved.upcase}"
1114
+ end
1115
+ end
1116
+ end
1117
+
1118
+ # : (untyped spec) -> ::Hash[Symbol, ::String]?
1119
+ def color_tag_attrs(spec)
1120
+ return nil unless spec
1121
+
1122
+ spec = spec[:color] if spec.is_a?(Hash) && spec[:color].is_a?(Hash)
1123
+ return nil unless spec
1124
+
1125
+ if spec.is_a?(Hash)
1126
+ if spec[:auto]
1127
+ { auto: "1" }
1128
+ elsif spec[:rgb] || (spec[:color] && !spec[:color].is_a?(Hash))
1129
+ raw_rgb = spec[:rgb] || spec[:color]
1130
+ fmt = format_rgb_hex(raw_rgb)
1131
+ fmt ? { rgb: fmt } : nil
1132
+ elsif spec[:theme]
1133
+ attrs = { theme: spec[:theme].to_s }
1134
+ attrs[:tint] = spec[:tint].to_s if spec[:tint]
1135
+ attrs
1136
+ elsif spec[:indexed]
1137
+ { indexed: spec[:indexed].to_s }
841
1138
  end
1139
+ elsif spec.is_a?(String) || spec.is_a?(Symbol)
1140
+ fmt = format_rgb_hex(spec)
1141
+ fmt ? { rgb: fmt } : nil
842
1142
  end
843
1143
  end
844
1144
 
@@ -849,6 +1149,7 @@ module Xlsxrb
849
1149
 
850
1150
  def escape_xml(value)
851
1151
  str = value.to_s
1152
+ str = str.dup.force_encoding(Encoding::UTF_8) if str.encoding == Encoding::BINARY
852
1153
  str = str.gsub(INVALID_XML_CHARS_RE, "") if str.match?(INVALID_XML_CHARS_RE)
853
1154
  str.match?(ESCAPE_RE) ? str.gsub(ESCAPE_RE, ESCAPE_MAP) : str
854
1155
  end
@@ -873,19 +1174,26 @@ module Xlsxrb
873
1174
  buf << "<i/>" if font[:italic]
874
1175
  buf << "<strike/>" if font[:strike]
875
1176
  if font[:underline]
876
- if font[:underline] == true
1177
+ if font[:underline] == true || font[:underline].to_s == "single"
877
1178
  buf << "<u/>"
878
1179
  else
879
- buf << '<u val="' << font[:underline].to_s << '"/>'
1180
+ mapped_u = StyleBuilder::UNDERLINE_MAP[font[:underline].to_sym] || font[:underline]
1181
+ buf << '<u val="' << mapped_u.to_s << '"/>'
880
1182
  end
881
1183
  end
882
- buf << '<vertAlign val="' << font[:vert_align].to_s << '"/>' if font[:vert_align]
883
- buf << '<sz val="' << font[:sz].to_s << '"/>' if font[:sz]
884
- if font[:color]
885
- buf << '<color rgb="' << font[:color].to_s << '"/>'
886
- elsif font[:theme]
887
- tint_attr = font[:tint] ? " tint=\"#{font[:tint]}\"" : ""
888
- buf << '<color theme="' << font[:theme].to_s << '"' << tint_attr << "/>"
1184
+ va_val = font[:vert_align] || font[:vertical_align]
1185
+ va_val ||= "superscript" if font[:superscript]
1186
+ va_val ||= "subscript" if font[:subscript]
1187
+ if va_val
1188
+ mapped_va = StyleBuilder::VERT_ALIGN_MAP[va_val.to_sym] || va_val.to_s
1189
+ buf << '<vertAlign val="' << mapped_va << '"/>'
1190
+ end
1191
+ font_sz = font[:sz] || font[:size]
1192
+ buf << '<sz val="' << font_sz.to_s << '"/>' if font_sz
1193
+ color_attrs = color_tag_attrs(font[:color] || font)
1194
+ if color_attrs
1195
+ attrs_str = color_attrs.map { |k, v| %(#{k}="#{v}") }.join(" ")
1196
+ buf << "<color #{attrs_str}/>"
889
1197
  end
890
1198
  buf << '<rFont val="' << escape_xml(font[:name]) << '"/>' if font[:name]
891
1199
  buf << '<family val="' << font[:family].to_s << '"/>' if font[:family]
@@ -894,17 +1202,11 @@ module Xlsxrb
894
1202
  else
895
1203
  buf << "<r><t>"
896
1204
  end
897
- buf << escape_xml(run[:text]) << "</t></r>"
1205
+ buf << escape_xml(run[:text].to_s) << "</t></r>"
898
1206
  end
899
1207
  buf << "</si>"
900
1208
  else
901
- s = str.to_s
902
- needs_space = s.start_with?(" ", "\t", "\n") || s.end_with?(" ", "\t", "\n")
903
- if needs_space
904
- buf << '<si><t xml:space="preserve">' << escape_xml(s) << "</t></si>"
905
- else
906
- buf << "<si><t>" << escape_xml(s) << "</t></si>"
907
- end
1209
+ buf << "<si><t>" << escape_xml(str.to_s) << "</t></si>"
908
1210
  end
909
1211
 
910
1212
  if buf.bytesize >= 32_768
@@ -919,16 +1221,17 @@ module Xlsxrb
919
1221
  zip.finish_entry
920
1222
  end
921
1223
 
922
- def write_worksheet_xml(io, sheet, drawing_rid: nil, sheet_protection: nil,
1224
+ def write_worksheet_xml(io, sheet, drawing_rid: nil, picture_rid: nil, sheet_protection: nil,
923
1225
  protected_ranges: nil, auto_filter: nil, filter_columns: nil,
924
1226
  sort_state: nil, merge_cells: nil, conditional_formats: nil,
925
1227
  data_validations: nil, hyperlinks: nil,
926
1228
  print_options: nil, page_margins: nil, page_setup: nil,
927
1229
  header_footer: nil, row_breaks: nil, col_breaks: nil,
928
- cell_watches: nil, freeze_pane: nil, split_pane: nil,
1230
+ cell_watches: nil, ignored_errors: nil, freeze_pane: nil, split_pane: nil,
929
1231
  selection: nil, sheet_view: nil, sheet_properties: nil,
930
1232
  sheet_format: nil, tables: nil, table_start_rid: nil,
931
- legacy_drawing_rid: nil, sparkline_groups: nil)
1233
+ legacy_drawing_rid: nil, sparkline_groups: nil, phonetic_pr: nil,
1234
+ sheet_calc_pr: nil)
932
1235
  date1904 = @workbook_properties&.[](:date1904) ? true : false
933
1236
  ws = WorksheetWriter.new(io, date1904: date1904)
934
1237
  ws.start(
@@ -964,6 +1267,7 @@ module Xlsxrb
964
1267
  end
965
1268
  ws.finish(
966
1269
  drawing_rid: drawing_rid,
1270
+ picture_rid: picture_rid,
967
1271
  sheet_protection: sheet_protection,
968
1272
  protected_ranges: protected_ranges,
969
1273
  auto_filter: auto_filter,
@@ -980,10 +1284,13 @@ module Xlsxrb
980
1284
  row_breaks: row_breaks,
981
1285
  col_breaks: col_breaks,
982
1286
  cell_watches: cell_watches,
1287
+ ignored_errors: ignored_errors,
983
1288
  tables: tables,
984
1289
  table_start_rid: table_start_rid,
985
1290
  legacy_drawing_rid: legacy_drawing_rid,
986
- sparkline_groups: sparkline_groups
1291
+ sparkline_groups: sparkline_groups,
1292
+ phonetic_pr: phonetic_pr,
1293
+ sheet_calc_pr: sheet_calc_pr
987
1294
  )
988
1295
  end
989
1296
 
@@ -1002,14 +1309,36 @@ module Xlsxrb
1002
1309
  t_attrs[:totalsRowCount] = tbl[:totals_row_count].to_s if tbl[:totals_row_count]&.positive?
1003
1310
  b.open_tag("table", t_attrs)
1004
1311
 
1005
- # Auto filter for the table range
1006
- b.empty_tag("autoFilter", { ref: tbl[:ref] })
1312
+ # Auto filter for the table range unless auto_filter: false
1313
+ b.empty_tag("autoFilter", { ref: tbl[:ref] }) unless tbl[:auto_filter] == false
1007
1314
 
1008
1315
  # Table columns
1009
1316
  columns = tbl[:columns] || []
1010
1317
  b.open_tag("tableColumns", { count: columns.size.to_s })
1011
- columns.each_with_index do |col_name, ci|
1012
- b.empty_tag("tableColumn", { id: (ci + 1).to_s, name: col_name })
1318
+ columns.each_with_index do |col, ci|
1319
+ col_name = col.is_a?(Hash) ? col[:name] : col
1320
+ col_attrs = { id: (ci + 1).to_s, name: col_name.to_s }
1321
+ calc_formula = nil
1322
+ tot_formula = nil
1323
+ if col.is_a?(Hash)
1324
+ calc_formula = col[:calculated_column_formula] || col[:data_formula] || col[:formula]
1325
+ tot_formula = col[:totals_row_formula] || col[:total_row_formula] || col[:total_formula]
1326
+ trf = col[:totals_row_function] || ("custom" if tot_formula)
1327
+ col_attrs[:totalsRowFunction] = trf.to_s if trf
1328
+ col_attrs[:totalsRowLabel] = col[:totals_row_label].to_s if col[:totals_row_label]
1329
+ col_attrs[:dataDxfId] = col[:data_dxf_id].to_s if col[:data_dxf_id]
1330
+ col_attrs[:totalsRowDxfId] = col[:totals_row_dxf_id].to_s if col[:totals_row_dxf_id]
1331
+ col_attrs[:headerRowDxfId] = col[:header_row_dxf_id].to_s if col[:header_row_dxf_id]
1332
+ col_attrs[:dataCellStyle] = col[:data_cell_style].to_s if col[:data_cell_style]
1333
+ end
1334
+ if calc_formula || tot_formula
1335
+ b.open_tag("tableColumn", col_attrs)
1336
+ b.tag("calculatedColumnFormula") { |cb| cb.text(calc_formula.to_s) } if calc_formula
1337
+ b.tag("totalsRowFormula") { |tb| tb.text(tot_formula.to_s) } if tot_formula
1338
+ b.close_tag("tableColumn")
1339
+ else
1340
+ b.empty_tag("tableColumn", col_attrs)
1341
+ end
1013
1342
  end
1014
1343
  b.close_tag("tableColumns")
1015
1344
 
@@ -1124,6 +1453,62 @@ module Xlsxrb
1124
1453
  b.close_tag("Properties")
1125
1454
  io.string
1126
1455
  end
1456
+
1457
+ def resolve_active_tab_index
1458
+ active_tab = @workbook_properties&.[](:active_tab)
1459
+ if active_tab.nil?
1460
+ first_vis_idx = @sheets.find_index do |s|
1461
+ state = s.respond_to?(:state) ? s.state : s[:state]
1462
+ state.nil? || state.to_sym == :visible
1463
+ end
1464
+ active_tab = first_vis_idx if first_vis_idx&.positive?
1465
+ end
1466
+
1467
+ if active_tab.is_a?(String) || active_tab.is_a?(Symbol)
1468
+ target_name = active_tab.to_s
1469
+ idx = @sheets.find_index do |s|
1470
+ (s.respond_to?(:name) ? s.name : s[:name]) == target_name
1471
+ end
1472
+ raise ArgumentError, "Active sheet '#{target_name}' not found" if idx.nil?
1473
+
1474
+ active_tab = idx
1475
+ end
1476
+
1477
+ if active_tab
1478
+ target_sheet = @sheets[active_tab]
1479
+ raise ArgumentError, "Active sheet index #{active_tab} out of bounds" if target_sheet.nil?
1480
+
1481
+ state = target_sheet.respond_to?(:state) ? target_sheet.state : target_sheet[:state]
1482
+ if state && state.to_sym != :visible
1483
+ name = target_sheet.respond_to?(:name) ? target_sheet.name : target_sheet[:name]
1484
+ raise ArgumentError, "Cannot set hidden sheet '#{name}' as active sheet"
1485
+ end
1486
+ end
1487
+
1488
+ active_tab
1489
+ end
1490
+
1491
+ def resolve_first_sheet_index
1492
+ first_sheet = @workbook_properties&.[](:first_sheet)
1493
+ return nil if first_sheet.nil?
1494
+
1495
+ if first_sheet.is_a?(String) || first_sheet.is_a?(Symbol)
1496
+ target_name = first_sheet.to_s
1497
+ idx = @sheets.find_index do |s|
1498
+ (s.respond_to?(:name) ? s.name : s[:name]) == target_name
1499
+ end
1500
+ raise ArgumentError, "First sheet '#{target_name}' not found" if idx.nil?
1501
+
1502
+ first_sheet = idx
1503
+ end
1504
+
1505
+ if first_sheet
1506
+ target_sheet = @sheets[first_sheet]
1507
+ raise ArgumentError, "First sheet index #{first_sheet} out of bounds" if target_sheet.nil?
1508
+ end
1509
+
1510
+ first_sheet
1511
+ end
1127
1512
  end
1128
1513
  end
1129
1514
  end