xlsxrb 0.1.4 → 0.1.6

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +1 -1
  3. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +9 -0
  4. data/.gem_rbs_collection/ast/2.4/ast.rbs +73 -0
  5. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  6. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  7. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +16 -0
  8. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +96 -0
  9. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  10. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +68 -0
  11. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  12. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +4 -0
  13. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +47 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  15. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +9 -0
  16. data/.gem_rbs_collection/csv/3.3/csv.rbs +3871 -0
  17. data/.gem_rbs_collection/csv/3.3/manifest.yaml +3 -0
  18. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +9 -0
  19. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +48 -0
  20. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  21. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  22. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  23. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/logger/1.7/formatter.rbs +45 -0
  25. data/.gem_rbs_collection/logger/1.7/log_device.rbs +100 -0
  26. data/.gem_rbs_collection/logger/1.7/logger.rbs +796 -0
  27. data/.gem_rbs_collection/logger/1.7/manifest.yaml +2 -0
  28. data/.gem_rbs_collection/logger/1.7/period.rbs +17 -0
  29. data/.gem_rbs_collection/logger/1.7/severity.rbs +34 -0
  30. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
  32. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
  33. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  35. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/parser/3.2/manifest.yaml +7 -0
  37. data/.gem_rbs_collection/parser/3.2/parser.rbs +194 -0
  38. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +4 -0
  39. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +9 -0
  40. data/.gem_rbs_collection/rainbow/3.0/global.rbs +7 -0
  41. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +209 -0
  42. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +5 -0
  43. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  44. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  45. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  46. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +9 -0
  47. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +17 -0
  48. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +9 -0
  49. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +208 -0
  50. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  51. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +903 -0
  52. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
  53. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
  54. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
  55. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
  56. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
  57. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
  58. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
  59. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
  60. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
  61. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
  62. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
  63. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
  64. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
  65. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
  66. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
  67. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
  68. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
  69. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
  70. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
  71. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
  72. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
  73. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
  74. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
  75. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
  76. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
  77. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
  78. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
  79. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
  80. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
  81. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
  82. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
  83. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
  84. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
  85. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
  86. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
  87. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
  88. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
  89. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
  90. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
  91. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
  92. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
  93. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
  94. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
  95. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
  96. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
  97. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
  98. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
  99. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
  100. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
  101. data/CHANGELOG.md +42 -3
  102. data/README.md +130 -49
  103. data/Rakefile +23 -2
  104. data/Steepfile +17 -0
  105. data/benchmark.rb +324 -348
  106. data/docs/ARCHITECTURE.md +24 -0
  107. data/docs/DEVELOPMENT.md +58 -0
  108. data/docs/QUALITY_ASSURANCE.md +26 -0
  109. data/docs/visual/VisualGallery.md +893 -938
  110. data/docs/wasm/ruby.wasm +0 -0
  111. data/lib/xlsxrb/elements/cell.rb +113 -8
  112. data/lib/xlsxrb/elements/column.rb +2 -0
  113. data/lib/xlsxrb/elements/row.rb +52 -4
  114. data/lib/xlsxrb/elements/types.rb +6 -0
  115. data/lib/xlsxrb/elements/workbook.rb +29 -0
  116. data/lib/xlsxrb/elements/worksheet.rb +98 -1
  117. data/lib/xlsxrb/elements.rb +2 -0
  118. data/lib/xlsxrb/ooxml/reader.rb +8 -6
  119. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +106 -40
  120. data/lib/xlsxrb/ooxml/styles_parser.rb +2 -0
  121. data/lib/xlsxrb/ooxml/utils.rb +2 -0
  122. data/lib/xlsxrb/ooxml/workbook_parser.rb +6 -1
  123. data/lib/xlsxrb/ooxml/workbook_writer.rb +15 -4
  124. data/lib/xlsxrb/ooxml/worksheet_parser.rb +156 -23
  125. data/lib/xlsxrb/ooxml/worksheet_writer.rb +207 -152
  126. data/lib/xlsxrb/ooxml/writer.rb +217 -5
  127. data/lib/xlsxrb/ooxml/xml_builder.rb +18 -10
  128. data/lib/xlsxrb/ooxml/xml_parser.rb +2 -0
  129. data/lib/xlsxrb/ooxml/zip_generator.rb +5 -8
  130. data/lib/xlsxrb/ooxml/zip_reader.rb +179 -57
  131. data/lib/xlsxrb/ooxml/zip_writer.rb +65 -25
  132. data/lib/xlsxrb/ooxml.rb +3 -1
  133. data/lib/xlsxrb/style_builder.rb +284 -36
  134. data/lib/xlsxrb/version.rb +3 -1
  135. data/lib/xlsxrb.rb +1761 -275
  136. data/rbs_collection.lock.yaml +252 -0
  137. data/rbs_collection.yaml +19 -0
  138. data/sig/generated/xlsxrb/elements/cell.rbs +30 -0
  139. data/sig/generated/xlsxrb/elements/column.rbs +30 -0
  140. data/sig/generated/xlsxrb/elements/row.rbs +32 -0
  141. data/sig/generated/xlsxrb/elements/types.rbs +74 -0
  142. data/sig/generated/xlsxrb/elements/workbook.rbs +25 -0
  143. data/sig/generated/xlsxrb/elements/worksheet.rbs +27 -0
  144. data/sig/generated/xlsxrb/elements.rbs +8 -0
  145. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  146. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  147. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  148. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  149. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  150. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  151. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  152. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  153. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  154. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  155. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  156. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  157. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  158. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  159. data/sig/generated/xlsxrb/ooxml.rbs +23 -0
  160. data/sig/generated/xlsxrb/style_builder.rbs +250 -0
  161. data/sig/generated/xlsxrb/version.rbs +5 -0
  162. data/sig/generated/xlsxrb.rbs +1276 -0
  163. data/sig/rexml.rbs +4 -0
  164. metadata +145 -4
  165. data/measure_memory.rb +0 -42
  166. data/sig/xlsxrb.rbs +0 -23
data/lib/xlsxrb.rb CHANGED
@@ -1,9 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "date"
6
+ require "time"
4
7
  require "openssl"
5
8
  require "securerandom"
6
9
  require "tempfile"
10
+ begin
11
+ require "bigdecimal"
12
+ rescue LoadError
13
+ # simplecov:disable
14
+ # Define a dummy class for environments without bigdecimal (e.g., ruby.wasm).
15
+ # This serves only as a fallback to prevent NameError in `case` statements (`when BigDecimal`).
16
+ # Impossible to cover in standard test environment because bigdecimal is present.
17
+ Object.const_set(:BigDecimal, Class.new)
18
+ # simplecov:enable
19
+ end
7
20
  require "opentelemetry"
8
21
  require_relative "xlsxrb/version"
9
22
  require_relative "xlsxrb/ooxml/zip_generator"
@@ -15,26 +28,74 @@ require_relative "xlsxrb/style_builder"
15
28
 
16
29
  # Ruby XLSX read/write library.
17
30
  module Xlsxrb
18
- class Error < StandardError; end
31
+ class Error < StandardError
32
+ DIV0 = Elements::CellError.new(code: "#DIV/0!")
33
+ NA = Elements::CellError.new(code: "#N/A")
34
+ NAME = Elements::CellError.new(code: "#NAME?")
35
+ NULL = Elements::CellError.new(code: "#NULL!")
36
+ NUM = Elements::CellError.new(code: "#NUM!")
37
+ REF = Elements::CellError.new(code: "#REF!")
38
+ VALUE = Elements::CellError.new(code: "#VALUE!")
39
+ end
40
+
41
+ class ParseError < Error; end
42
+ class ValidationError < Error; end
43
+ class ZipError < Error; end
19
44
 
20
45
  TRACER = OpenTelemetry.tracer_provider.tracer("xlsxrb", Xlsxrb::VERSION)
21
46
 
47
+ def self.in_span(name, attributes: nil, &)
48
+ if defined?(Ractor) && Ractor.current != Ractor.main
49
+ # simplecov:disable
50
+ # Test suite runs in the main Ractor. This branch is for multi-threaded usage via Ractors.
51
+ yield
52
+ # simplecov:enable
53
+ elsif attributes
54
+ TRACER.in_span(name, attributes: attributes, &)
55
+ else
56
+ TRACER.in_span(name, &)
57
+ end
58
+ end
59
+
60
+ # Helper to easily create RichText objects.
61
+ # Supports both `Xlsxrb.rich_text({ text: "A" }, { text: "B" })`
62
+ # and `Xlsxrb.rich_text(text: "Hi", bold: true)`
63
+ #
64
+ # @param runs [Array<Hash>] Optional rich text runs.
65
+ # @param text [String, nil] Simple text.
66
+ # @param font_props [Hash] Font styling options (e.g., bold: true).
67
+ # @return [Elements::RichText] The resulting rich text.
68
+ # @api public
69
+ #: (*untyped runs, ?text: String?, **untyped font_props) -> untyped
70
+ def self.rich_text(*runs, text: nil, **font_props)
71
+ runs = [{ text: text, font: font_props }] if text
72
+ Elements::RichText.new(runs: runs)
73
+ end
74
+
22
75
  # Builder for block-style chart definitions.
76
+ # @api public
23
77
  class ChartBuilder
78
+ #: () -> void
24
79
  def initialize
25
80
  @options = {}
26
81
  end
27
-
82
+ #: Hash[Symbol, untyped]
28
83
  attr_reader :options
29
84
 
85
+ # @api public
86
+ #: (untyped value) -> untyped
30
87
  def type(value) = @options[:type] = value
88
+ # @api public
89
+ #: (untyped value) -> untyped
31
90
  def title(value) = @options[:title] = value
32
91
 
33
- def series(value = nil, &block)
92
+ # @api public
93
+ #: (?Hash[Symbol, untyped]? value) ?{ (SeriesBuilder) -> void } -> Array[Hash[Symbol, untyped]]
94
+ def series(value = nil)
34
95
  @options[:series] ||= []
35
96
  if block_given?
36
97
  sb = SeriesBuilder.new
37
- block.call(sb)
98
+ yield sb
38
99
  @options[:series] << sb.options
39
100
  elsif value
40
101
  @options[:series] << value
@@ -42,30 +103,234 @@ module Xlsxrb
42
103
  @options[:series]
43
104
  end
44
105
 
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
106
+ # Configures the legend property for this chart.
107
+ # @param args [Array] Positional arguments.
108
+ # @param kwargs [Hash] Keyword arguments.
109
+ # @return [Object] the configured property
110
+ # @api public
111
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
112
+ def legend(*args, **kwargs)
113
+ @options[:legend] = kwargs.empty? ? args.first : kwargs
114
+ end
115
+
116
+ # Configures the plot_area property for this chart.
117
+ # @param args [Array] Positional arguments.
118
+ # @param kwargs [Hash] Keyword arguments.
119
+ # @return [Object] the configured property
120
+ # @api public
121
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
122
+ def plot_area(*args, **kwargs)
123
+ @options[:plot_area] = kwargs.empty? ? args.first : kwargs
124
+ end
125
+
126
+ # Configures the chart_space property for this chart.
127
+ # @param args [Array] Positional arguments.
128
+ # @param kwargs [Hash] Keyword arguments.
129
+ # @return [Object] the configured property
130
+ # @api public
131
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
132
+ def chart_space(*args, **kwargs)
133
+ @options[:chart_space] = kwargs.empty? ? args.first : kwargs
134
+ end
135
+
136
+ # Configures the style property for this chart.
137
+ # @param args [Array] Positional arguments.
138
+ # @param kwargs [Hash] Keyword arguments.
139
+ # @return [Object] the configured property
140
+ # @api public
141
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String | Integer) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String | Integer)
142
+ def style(*args, **kwargs)
143
+ @options[:style] = kwargs.empty? ? args.first : kwargs
144
+ end
145
+
146
+ # Configures the data_labels property for this chart.
147
+ # @param args [Array] Positional arguments.
148
+ # @param kwargs [Hash] Keyword arguments.
149
+ # @return [Object] the configured property
150
+ # @api public
151
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
152
+ def data_labels(*args, **kwargs)
153
+ @options[:data_labels] = kwargs.empty? ? args.first : kwargs
154
+ end
155
+
156
+ # Configures the plot_visible_only property for this chart.
157
+ # @param args [Array] Positional arguments.
158
+ # @param kwargs [Hash] Keyword arguments.
159
+ # @return [Object] the configured property
160
+ # @api public
161
+ #: (*(bool | String) args, **String | Integer | bool | nil kwargs) -> (bool | String)
162
+ def plot_visible_only(*args, **kwargs)
163
+ @options[:plot_visible_only] = kwargs.empty? ? args.first : kwargs
164
+ end
165
+
166
+ # Configures the display_blanks_as property for this chart.
167
+ # @param args [Array] Positional arguments.
168
+ # @param kwargs [Hash] Keyword arguments.
169
+ # @return [Object] the configured property
170
+ # @api public
171
+ #: (*(String) args, **String | Integer | bool | nil kwargs) -> String
172
+ def display_blanks_as(*args, **kwargs)
173
+ @options[:display_blanks_as] = kwargs.empty? ? args.first : kwargs
174
+ end
175
+
176
+ # Configures the view3d property for this chart.
177
+ # @param args [Array] Positional arguments.
178
+ # @param kwargs [Hash] Keyword arguments.
179
+ # @return [Object] the configured property
180
+ # @api public
181
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
182
+ def view3d(*args, **kwargs)
183
+ @options[:view3d] = kwargs.empty? ? args.first : kwargs
184
+ end
185
+
186
+ # Configures the category_axis property for this chart.
187
+ # @param args [Array] Positional arguments.
188
+ # @param kwargs [Hash] Keyword arguments.
189
+ # @return [Object] the configured property
190
+ # @api public
191
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
192
+ def category_axis(*args, **kwargs)
193
+ @options[:category_axis] = kwargs.empty? ? args.first : kwargs
194
+ end
195
+
196
+ # Configures the value_axis property for this chart.
197
+ # @param args [Array] Positional arguments.
198
+ # @param kwargs [Hash] Keyword arguments.
199
+ # @return [Object] the configured property
200
+ # @api public
201
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
202
+ def value_axis(*args, **kwargs)
203
+ @options[:value_axis] = kwargs.empty? ? args.first : kwargs
204
+ end
205
+
206
+ # Configures the show_legend_key property for this chart.
207
+ # @param args [Array] Positional arguments.
208
+ # @param kwargs [Hash] Keyword arguments.
209
+ # @return [Object] the configured property
210
+ # @api public
211
+ #: (*(bool | String) args, **String | Integer | bool | nil kwargs) -> (bool | String)
212
+ def show_legend_key(*args, **kwargs)
213
+ @options[:show_legend_key] = kwargs.empty? ? args.first : kwargs
52
214
  end
53
215
 
54
216
  # Builder for a single series entry in block-style chart definitions.
217
+ # @api public
55
218
  class SeriesBuilder
219
+ #: () -> void
56
220
  def initialize
57
221
  @options = {}
58
222
  end
59
-
223
+ #: Hash[Symbol, untyped]
60
224
  attr_reader :options
61
225
 
62
- def method_missing(name, *args, **kwargs, &)
63
- key = name.to_sym
64
- @options[key] = kwargs.empty? ? args.first : kwargs
226
+ # Configures the categories property for this series.
227
+ # @param args [Array] Positional arguments.
228
+ # @param kwargs [Hash] Keyword arguments.
229
+ # @return [Object] the configured property
230
+ # @api public
231
+ #: (*untyped args, **untyped kwargs) -> untyped
232
+ def categories(*args, **kwargs)
233
+ @options[:categories] = kwargs.empty? ? args.first : kwargs
234
+ end
235
+
236
+ # Configures the values property for this series.
237
+ # @param args [Array] Positional arguments.
238
+ # @param kwargs [Hash] Keyword arguments.
239
+ # @return [Object] the configured property
240
+ # @api public
241
+ #: (*untyped args, **untyped kwargs) -> untyped
242
+ def values(*args, **kwargs)
243
+ @options[:values] = kwargs.empty? ? args.first : kwargs
244
+ end
245
+
246
+ # Configures the name property for this series.
247
+ # @param args [Array] Positional arguments.
248
+ # @param kwargs [Hash] Keyword arguments.
249
+ # @return [Object] the configured property
250
+ # @api public
251
+ #: (*untyped args, **untyped kwargs) -> untyped
252
+ def name(*args, **kwargs)
253
+ @options[:name] = kwargs.empty? ? args.first : kwargs
254
+ end
255
+
256
+ # Configures the marker property for this series.
257
+ # @param args [Array] Positional arguments.
258
+ # @param kwargs [Hash] Keyword arguments.
259
+ # @return [Object] the configured property
260
+ # @api public
261
+ #: (*untyped args, **untyped kwargs) -> untyped
262
+ def marker(*args, **kwargs)
263
+ @options[:marker] = kwargs.empty? ? args.first : kwargs
264
+ end
265
+
266
+ # Configures the fill property for this series.
267
+ # @param args [Array] Positional arguments.
268
+ # @param kwargs [Hash] Keyword arguments.
269
+ # @return [Object] the configured property
270
+ # @api public
271
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
272
+ def fill(*args, **kwargs)
273
+ @options[:fill] = kwargs.empty? ? args.first : kwargs
274
+ end
275
+
276
+ # Configures the line property for this series.
277
+ # @param args [Array] Positional arguments.
278
+ # @param kwargs [Hash] Keyword arguments.
279
+ # @return [Object] the configured property
280
+ # @api public
281
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
282
+ def line(*args, **kwargs)
283
+ @options[:line] = kwargs.empty? ? args.first : kwargs
284
+ end
285
+
286
+ # Configures the trendline property for this series.
287
+ # @param args [Array] Positional arguments.
288
+ # @param kwargs [Hash] Keyword arguments.
289
+ # @return [Object] the configured property
290
+ # @api public
291
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
292
+ def trendline(*args, **kwargs)
293
+ @options[:trendline] = kwargs.empty? ? args.first : kwargs
65
294
  end
66
295
 
67
- def respond_to_missing?(_name, _include_private = false)
68
- true
296
+ # Configures the data_labels property for this series.
297
+ # @param args [Array] Positional arguments.
298
+ # @param kwargs [Hash] Keyword arguments.
299
+ # @return [Object] the configured property
300
+ # @api public
301
+ #: (*(Hash[Symbol, String | Integer | bool | nil] | String) args, **String | Integer | bool | nil kwargs) -> (Hash[Symbol, String | Integer | bool | nil] | String)
302
+ def data_labels(*args, **kwargs)
303
+ @options[:data_labels] = kwargs.empty? ? args.first : kwargs
304
+ end
305
+
306
+ # Configures the smooth property for this series.
307
+ # @param args [Array] Positional arguments.
308
+ # @param kwargs [Hash] Keyword arguments.
309
+ # @return [Object] the configured property
310
+ # @api public
311
+ #: (*(bool | String) args, **String | Integer | bool | nil kwargs) -> (bool | String)
312
+ def smooth(*args, **kwargs)
313
+ @options[:smooth] = kwargs.empty? ? args.first : kwargs
314
+ end
315
+
316
+ # Configures the shape property for this series.
317
+ # @param args [Array] Positional arguments.
318
+ # @param kwargs [Hash] Keyword arguments.
319
+ # @return [Object] the configured property
320
+ # @api public
321
+ #: (*(String) args, **String | Integer | bool | nil kwargs) -> String
322
+ def shape(*args, **kwargs)
323
+ @options[:shape] = kwargs.empty? ? args.first : kwargs
324
+ end
325
+
326
+ # Configures the type property for this series.
327
+ # @param args [Array] Positional arguments.
328
+ # @param kwargs [Hash] Keyword arguments.
329
+ # @return [Object] the configured property
330
+ # @api public
331
+ #: (*(String) args, **String | Integer | bool | nil kwargs) -> String
332
+ def type(*args, **kwargs)
333
+ @options[:type] = kwargs.empty? ? args.first : kwargs
69
334
  end
70
335
  end
71
336
  end
@@ -74,9 +339,13 @@ module Xlsxrb
74
339
  # Supports method_missing for setting arbitrary keys.
75
340
  # --- Facade API ---
76
341
 
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.
342
+ # Creates a Formula object for use in row values.
343
+ #
344
+ # @param expression [String] The formula text (e.g. "SUM(A1:A10)").
345
+ # @param cached_value [Object, nil] Optional cached result. If nil, Excel will calculate on open.
346
+ # @return [Elements::Formula]
347
+ # @api public
348
+ #: (String expression, ?cached_value: String | Numeric | bool | nil) -> untyped
80
349
  def self.formula(expression, cached_value: nil)
81
350
  Elements::Formula.new(
82
351
  expression: expression,
@@ -86,10 +355,14 @@ module Xlsxrb
86
355
  end
87
356
 
88
357
  # Reads an XLSX file into an Elements::Workbook.
89
- # source: file path (String) or IO object.
358
+ #
359
+ # @param source [String, IO] File path or IO object.
360
+ # @return [Elements::Workbook] The parsed workbook.
361
+ # @api public
362
+ #: (untyped source) -> untyped
90
363
  def self.read(source)
91
364
  attributes = source.is_a?(String) ? { "filepath" => source } : {}
92
- TRACER.in_span("Xlsxrb.read", attributes: attributes) do
365
+ Xlsxrb.in_span("Xlsxrb.read", attributes: attributes) do
93
366
  entries = Ooxml::ZipReader.open(source, &:read_all)
94
367
  shared_strings = Ooxml::SharedStringsParser.parse(entries["xl/sharedStrings.xml"])
95
368
  styles = Ooxml::StylesParser.parse(entries["xl/styles.xml"])
@@ -110,13 +383,18 @@ module Xlsxrb
110
383
  end
111
384
 
112
385
  # Writes an Elements::Workbook to an XLSX file.
113
- # target: file path (String) or IO object.
386
+ #
387
+ # @param target [String, IO] File path or IO object.
388
+ # @param workbook [Elements::Workbook] The workbook to write.
389
+ # @return [void]
390
+ # @api public
391
+ #: (untyped target, untyped workbook) -> void
114
392
  def self.write(target, workbook)
115
393
  raise Error, "target is required" if target.nil?
116
394
  raise Error, "workbook must be an Elements::Workbook" unless workbook.is_a?(Elements::Workbook)
117
395
 
118
396
  attributes = target.is_a?(String) ? { "filepath" => target } : {}
119
- TRACER.in_span("Xlsxrb.write", attributes: attributes) do
397
+ Xlsxrb.in_span("Xlsxrb.write", attributes: attributes) do
120
398
  sst = []
121
399
  sst_index = {}
122
400
 
@@ -132,7 +410,10 @@ module Xlsxrb
132
410
  end
133
411
  end
134
412
  columns = ws.columns.map do |col|
413
+ # simplecov:disable
414
+ # Edge case / untested delegation block
135
415
  { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level }
416
+ # simplecov:enable
136
417
  end
137
418
  sd = { name: ws.name, rows: ws.rows, columns: columns }
138
419
  sd[:charts] = ws.charts unless ws.charts.empty?
@@ -156,101 +437,157 @@ module Xlsxrb
156
437
  core_properties: wb_facade[:core_properties],
157
438
  app_properties: wb_facade[:app_properties],
158
439
  custom_properties: wb_facade[:custom_properties],
159
- workbook_protection: wb_facade[:workbook_protection]
440
+ workbook_protection: wb_facade[:workbook_protection],
441
+ workbook_properties: wb_facade[:workbook_properties]
160
442
  )
161
443
  end
162
444
  end
163
445
 
164
446
  # Modifies an existing XLSX file.
165
447
  # 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`).
448
+ # The block receives an Elements::Workbook and must return a modified one (e.g. via `update_sheet`).
167
449
  # If no target is given, the source is overwritten.
168
450
  #
169
- # Example:
451
+ # @example
170
452
  # 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 })
453
+ # wb.update_sheet(0) do |sheet|
454
+ # sheet.update_cell("B1", value: "Updated")
455
+ # .update_cell("B2", value: 100)
456
+ # end
177
457
  # end
178
- def self.modify(source, target = nil, &block)
458
+ #
459
+ # @param source [String, IO] The source file path or IO object.
460
+ # @param target [String, IO, nil] The target file path or IO object. If nil, overwrites source.
461
+ # @yield [workbook] Yields the parsed workbook.
462
+ # @yieldparam workbook [Elements::Workbook] The parsed workbook.
463
+ # @yieldreturn [Elements::Workbook] The modified workbook.
464
+ # @return [void]
465
+ # @api public
466
+ #: (untyped source, ?untyped target) ?{ (untyped) -> untyped } -> void
467
+ def self.modify(source, target = nil)
179
468
  raise Error, "source is required" if source.nil?
180
- raise Error, "block is required" unless block
469
+ raise Error, "block is required" unless block_given?
181
470
 
182
471
  workbook = read(source)
183
- result_workbook = block.call(workbook)
472
+ result_workbook = yield workbook
184
473
  result_workbook = workbook unless result_workbook.is_a?(Elements::Workbook)
185
474
 
186
475
  write_target = target || source
187
476
  write(write_target, result_workbook)
188
477
  end
189
478
 
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
479
+ # Represents a sheet being streamed sequentially.
480
+ class StreamSheet
481
+ [Enumerable].each { |m| include m }
482
+
483
+ attr_reader :name
484
+
485
+ def initialize(name, sheet_xml, shared_strings)
486
+ @name = name
487
+ @sheet_xml = sheet_xml
488
+ @shared_strings = shared_strings
489
+ end
490
+
491
+ #: () { (Elements::Row) -> void } -> void
492
+ #: | () -> Enumerator[Elements::Row, void]
493
+ def each_row
494
+ return enum_for(:each_row) unless block_given?
495
+
496
+ Ooxml::WorksheetParser.each_row(@sheet_xml, shared_strings: @shared_strings) do |row|
497
+ if row.is_a?(Elements::Row)
498
+ yield row
499
+ else
500
+ yield Xlsxrb.send(:build_row_from_raw, row)
501
+ end
502
+ end
503
+ end
504
+
505
+ #: () { (Elements::Row) -> void } -> void
506
+ #: | () -> Enumerator[Elements::Row, void]
507
+ def each(&)
508
+ each_row(&)
509
+ end
510
+ end
511
+
512
+ # Streaming read: yields StreamSheet objects one at a time for each sheet.
513
+ #
514
+ # @param source [String, IO] File path or IO object.
515
+ # @yield [sheet] Yields each sheet.
516
+ # @yieldparam sheet [StreamSheet] The streaming sheet object.
517
+ # @return [Enumerator] If no block is given.
518
+ # @return [void]
519
+ # @api public
520
+ #: (untyped source) ?{ (StreamSheet) -> void } -> untyped
521
+ def self.foreach(source)
522
+ return enum_for(:foreach, source) unless block_given?
196
523
 
197
524
  attributes = source.is_a?(String) ? { "filepath" => source } : {}
198
- TRACER.in_span("Xlsxrb.foreach", attributes: attributes) do
525
+ Xlsxrb.in_span("Xlsxrb.foreach", attributes: attributes) do
199
526
  entries = Ooxml::ZipReader.open(source, &:read_all)
200
527
  shared_strings = Ooxml::SharedStringsParser.parse(entries["xl/sharedStrings.xml"])
201
528
  workbook_sheets = Ooxml::WorkbookParser.parse(entries["xl/workbook.xml"])
202
529
  rels = Ooxml::RelationshipsParser.parse(entries["xl/_rels/workbook.xml.rels"])
203
530
 
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
531
+ workbook_sheets.each do |sheet_info|
532
+ target = rels[sheet_info[:r_id]]
533
+ next unless target
214
534
 
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?
535
+ sheet_path = target.start_with?("/") ? target.delete_prefix("/") : "xl/#{target}"
536
+ sheet_xml = entries[sheet_path]
537
+ next if sheet_xml.nil? || sheet_xml.empty?
218
538
 
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)
539
+ yield StreamSheet.new(sheet_info[:name], sheet_xml, shared_strings)
222
540
  end
223
541
  end
224
542
  end
225
543
 
226
544
  # 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)
545
+ #
546
+ # @param target [String, IO] File path or IO object.
547
+ # @yield [stream_writer]
548
+ # @yieldparam stream_writer [Xlsxrb::StreamWriter]
549
+ # @return [void]
550
+ # @api public
551
+ #: (untyped target, ?strict_excel_mode: bool) ?{ (Xlsxrb::StreamWriter) -> void } -> void
552
+ def self.generate(target, strict_excel_mode: true)
229
553
  raise Error, "target is required" if target.nil?
230
- raise Error, "block is required" unless block
554
+ raise Error, "block is required" unless block_given?
231
555
 
232
556
  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
557
+ Xlsxrb.in_span("Xlsxrb.generate", attributes: attributes) do
558
+ stream_writer = StreamWriter.new(target, strict_excel_mode: strict_excel_mode)
559
+ begin
560
+ yield stream_writer
561
+ stream_writer.close
562
+ ensure
563
+ stream_writer.cleanup!
564
+ end
237
565
  end
238
566
  end
239
567
 
240
568
  # 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)
569
+ #
570
+ # @yield [builder]
571
+ # @yieldparam builder [Xlsxrb::WorkbookBuilder]
572
+ # @return [Elements::Workbook]
573
+ # @api public
574
+ #: (?strict_excel_mode: bool) ?{ (WorkbookBuilder) -> void } -> untyped
575
+ def self.build(strict_excel_mode: true)
576
+ raise Error, "block is required" unless block_given?
577
+
578
+ Xlsxrb.in_span("Xlsxrb.build") do
579
+ builder = WorkbookBuilder.new(strict_excel_mode: strict_excel_mode)
580
+ yield builder
247
581
  builder.build
248
582
  end
249
583
  end
250
584
 
251
585
  # DSL context for Xlsxrb.build.
586
+ # @api public
252
587
  class WorkbookBuilder
253
- def initialize
588
+ #: (?strict_excel_mode: bool) -> void
589
+ def initialize(strict_excel_mode: true)
590
+ @strict_excel_mode = strict_excel_mode
254
591
  @sheets = []
255
592
  @sheet_builders = [] # Keep track of sheet builders for style processing
256
593
  @defined_names = []
@@ -258,66 +595,157 @@ module Xlsxrb
258
595
  @app_properties = {}
259
596
  @custom_properties = []
260
597
  @workbook_protection = nil
598
+ @workbook_properties = { update_links: "never" }
599
+ end
600
+
601
+ # Set a workbook property.
602
+ #
603
+ # @note **SECURITY WARNING:** If you set `:update_links` to anything other than `"never"`,
604
+ # you may expose end-users to malicious external reference vulnerabilities (e.g., CSV/DDE Injection)
605
+ # when they open the generated Excel file. Ensure you fully trust the exported data.
606
+ #
607
+ # @param name [Symbol] The property name (e.g. :update_links).
608
+ # @param value [String, Integer, Boolean] The property value.
609
+ # @return [void]
610
+ # @api public
611
+ #: (Symbol name, String | Integer | bool value) -> (String | Integer | bool)
612
+ def workbook_property(name, value)
613
+ @workbook_properties[name] = value
261
614
  end
262
615
 
263
616
  # Add a new sheet.
264
- def add_sheet(name = nil, &block)
617
+ #
618
+ # @param name [String, nil] The name of the sheet.
619
+ # @param opts [Hash] Sheet properties.
620
+ # @yield [sheet_builder]
621
+ # @yieldparam sheet_builder [Xlsxrb::WorksheetBuilder]
622
+ # @return [void]
623
+ # @api public
624
+ #: (?String? name, **untyped opts) ?{ (WorksheetBuilder) -> void } -> untyped
625
+ def sheet(name = nil, **opts)
265
626
  name ||= "Sheet#{@sheets.size + 1}"
266
- sheet_builder = WorksheetBuilder.new(name)
267
- block.call(sheet_builder) if block_given?
627
+ raise ArgumentError, "Sheet name '#{name}' must be <= 31 characters (Excel limitation)" if @strict_excel_mode && name.length > 31
628
+ raise ArgumentError, "Sheet name '#{name}' contains invalid characters (ECMA-376 OOXML specification)" if name.match?(%r{[\[\]*?/\\]})
629
+ raise ArgumentError, "Sheet name '#{name}' is already used. Excel requires unique sheet names." if @strict_excel_mode && @sheets.map { |s| s.respond_to?(:name) ? s.name.downcase : s.to_s.downcase }.include?(name.downcase)
630
+
631
+ sheet_builder = WorksheetBuilder.new(name, strict_excel_mode: @strict_excel_mode)
632
+ opts.each { |k, v| sheet_builder.sheet_properties(k, v) }
633
+ yield sheet_builder if block_given?
268
634
  @sheet_builders << sheet_builder
269
635
  @sheets << sheet_builder.build
270
636
  end
637
+ alias [] sheet
271
638
 
272
639
  # --- Workbook-Level Methods ---
273
640
 
274
641
  # Add a defined name.
275
- def add_defined_name(name, value, sheet: nil, hidden: false)
642
+ #
643
+ # @param name [String] The defined name.
644
+ # @param value [String] The formula or value.
645
+ # @param sheet [String, nil] Local sheet name.
646
+ # @param hidden [Boolean] Whether the defined name is hidden.
647
+ # @return [void]
648
+ # @api public
649
+ #: (String name, String value, ?sheet: String?, ?hidden: bool) -> void
650
+ def defined_name(name, value, sheet: nil, hidden: false)
276
651
  entry = { name: name, value: value, hidden: hidden }
277
652
  entry[:local_sheet_name] = sheet if sheet
278
653
  @defined_names << entry
279
654
  end
280
655
 
281
656
  # Set the print area for a sheet.
282
- def set_print_area(range, sheet: nil)
657
+ #
658
+ # @param range [String] The range string (e.g. "A1:B10").
659
+ # @param sheet [String, nil] The sheet name.
660
+ # @return [void]
661
+ # @api public
662
+ #: (String range, ?sheet: String?) -> void
663
+ def print_area(range, sheet: nil)
283
664
  sheet_name = sheet || @sheets.last&.name || "Sheet1"
284
665
  value = "'#{sheet_name}'!#{absolute_range(range)}"
285
666
  @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)
667
+ defined_name("_xlnm.Print_Area", value, sheet: sheet_name)
287
668
  end
288
669
 
289
670
  # Set print titles for a sheet.
290
- def set_print_titles(rows: nil, cols: nil, sheet: nil)
671
+ #
672
+ # @param rows [String, nil] Rows to repeat (e.g. "1:2").
673
+ # @param cols [String, nil] Columns to repeat (e.g. "A:B").
674
+ # @param sheet [String, nil] The sheet name.
675
+ # @return [void]
676
+ # @api public
677
+ #: (?rows: String?, ?cols: String?, ?sheet: String?) -> void
678
+ def print_titles(rows: nil, cols: nil, sheet: nil)
291
679
  sheet_name = sheet || @sheets.last&.name || "Sheet1"
292
680
  parts = []
293
681
  parts << "'#{sheet_name}'!$#{cols.sub(":", ":$")}" if cols
294
682
  parts << "'#{sheet_name}'!$#{rows.sub(":", ":$")}" if rows
295
683
  value = parts.join(",")
296
684
  @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)
685
+ defined_name("_xlnm.Print_Titles", value, sheet: sheet_name)
298
686
  end
299
687
 
300
688
  # Set workbook protection.
301
- def set_workbook_protection(**opts)
689
+ #
690
+ # @param opts [Hash] Protection options.
691
+ # @return [void]
692
+ # @api public
693
+ #: (**String | Integer | bool | nil opts) -> void
694
+ def protect_workbook(**opts)
302
695
  @workbook_protection = opts
303
696
  end
304
697
 
305
698
  # Set a core document property.
306
- def set_core_property(name, value)
699
+ #
700
+ # @param name [Symbol] The property name.
701
+ # @param value [String, Integer, Time] The property value.
702
+ # @return [void]
703
+ # @api public
704
+ #: (Symbol name, String | Integer | Time value) -> void
705
+ def core_property(name, value)
307
706
  @core_properties[name] = value
308
707
  end
309
708
 
310
709
  # Set an app document property.
311
- def set_app_property(name, value)
710
+ #
711
+ # @param name [Symbol] The property name.
712
+ # @param value [String, Integer, Time] The property value.
713
+ # @return [void]
714
+ # @api public
715
+ #: (Symbol name, String | Integer | Time value) -> void
716
+ def app_property(name, value)
312
717
  @app_properties[name] = value
313
718
  end
314
719
 
720
+ # Set multiple core and/or app properties.
721
+ #
722
+ # @param core [Hash, nil] Core properties.
723
+ # @param app [Hash, nil] App properties.
724
+ # @return [void]
725
+ # @api public
726
+ #: (?core: Hash[Symbol, String | Integer | Time]?, ?app: Hash[Symbol, String | Integer | Time]?) -> void
727
+ def properties(core: nil, app: nil)
728
+ core&.each { |k, v| core_property(k, v) }
729
+ app&.each { |k, v| app_property(k, v) }
730
+ end
731
+
315
732
  # Add a custom document property.
316
- def add_custom_property(name, value, type: :string)
733
+ #
734
+ # @param name [String] The property name.
735
+ # @param value [String, Integer, Float, Boolean, Time] The property value.
736
+ # @param type [Symbol] The type of property (:string, :number, :bool, :date).
737
+ # @return [void]
738
+ # @api public
739
+ #: (String name, String | Integer | Float | bool | Time value, ?type: ::Symbol) -> void
740
+ def custom_property(name, value, type: :string)
317
741
  @custom_properties << { name: name, value: value, type: type }
318
742
  end
319
743
 
744
+ # @api public
745
+ #: () -> untyped
320
746
  def build
747
+ raise ArgumentError, "Workbook must contain at least one sheet (Excel limitation)" if @strict_excel_mode && @sheets.empty?
748
+
321
749
  # Process styles from all sheets and collect style definitions
322
750
  processed_sheets, styles_definition = process_styles(@sheets)
323
751
 
@@ -328,6 +756,7 @@ module Xlsxrb
328
756
  wb_meta[:app_properties] = @app_properties unless @app_properties.empty?
329
757
  wb_meta[:custom_properties] = @custom_properties unless @custom_properties.empty?
330
758
  wb_meta[:workbook_protection] = @workbook_protection if @workbook_protection
759
+ wb_meta[:workbook_properties] = @workbook_properties unless @workbook_properties.empty?
331
760
 
332
761
  Elements::Workbook.new(
333
762
  sheets: processed_sheets,
@@ -338,10 +767,12 @@ module Xlsxrb
338
767
 
339
768
  private
340
769
 
770
+ #: (untyped range) -> untyped
341
771
  def absolute_range(range)
342
772
  range.gsub(/([A-Z]+)(\d+)/, '$\1$\2')
343
773
  end
344
774
 
775
+ #: (untyped names, untyped sheets) -> untyped
345
776
  def resolve_defined_names(names, sheets)
346
777
  sheet_names = sheets.map(&:name)
347
778
  names.map do |dn|
@@ -355,6 +786,7 @@ module Xlsxrb
355
786
  end
356
787
  end
357
788
 
789
+ #: (untyped sheets) -> (::Array[untyped | ::Hash[untyped, untyped]] | ::Array[untyped])
358
790
  def process_styles(sheets)
359
791
  # Collect all unique StyleBuilders from all sheets
360
792
  all_style_builders = {}
@@ -420,23 +852,27 @@ module Xlsxrb
420
852
  [updated_sheets, styles_definition]
421
853
  end
422
854
 
855
+ #: (untyped writer) -> { fonts: untyped, fills: untyped, borders: untyped, xf_entries: untyped, num_fmts: untyped }
423
856
  def extract_styles_from_writer(writer)
424
857
  # Extract style definitions from the writer that can be reused
425
858
  # This captures the fonts, fills, borders, and xf entries that were created
426
859
  {
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
860
+ fonts: writer.fonts.dup,
861
+ fills: writer.fills.dup,
862
+ borders: writer.borders.dup,
863
+ xf_entries: writer.xf_entries.dup,
864
+ num_fmts: writer.num_fmts.dup
432
865
  }
433
866
  end
434
867
  end
435
868
 
436
869
  # DSL context for a single worksheet in Xlsxrb.build.
870
+ # @api public
437
871
  class WorksheetBuilder
438
- def initialize(name)
872
+ #: (untyped name, ?strict_excel_mode: bool) -> void
873
+ def initialize(name, strict_excel_mode: true)
439
874
  @name = name
875
+ @strict_excel_mode = strict_excel_mode
440
876
  @rows = []
441
877
  @columns = []
442
878
  @charts = []
@@ -469,27 +905,127 @@ module Xlsxrb
469
905
  end
470
906
 
471
907
  # Define a named style that can be applied to cells.
472
- def add_style(name, **opts, &block)
908
+ #
909
+ # @param name [String] The name of the style.
910
+ # @param opts [Hash] Style options (e.g. bold: true).
911
+ # @yield [style_builder]
912
+ # @yieldparam style_builder [Xlsxrb::StyleBuilder]
913
+ # @return [StyleBuilder]
914
+ # @api public
915
+ #: (String name, **untyped opts) ?{ (StyleBuilder) -> void } -> StyleBuilder
916
+ def style(name, **opts)
473
917
  style_builder = StyleBuilder.new(name)
474
918
  style_builder.apply_options!(**opts) unless opts.empty?
475
- block.call(style_builder) if block_given?
919
+ yield style_builder if block_given?
476
920
  @styles[name] = style_builder
477
921
  style_builder
478
922
  end
479
923
 
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)
924
+ # Add a row to the sheet.
925
+ #
926
+ # @param values [Array, Hash] The cell values.
927
+ # @param styles [String, Array<String>, nil] Styles to apply to cells.
928
+ # @param height [Float, nil] The row height.
929
+ # @param hidden [Boolean] Whether the row is hidden.
930
+ # @param custom_height [Boolean] Whether it's a custom height.
931
+ # @param outline_level [Integer, nil] The outline level.
932
+ # @note Excel's column limit is 16,384, row limit is 1,048,576, string max length is 32,767.
933
+ # @return [void]
934
+ # @api public
935
+ #: (Array[untyped] | Hash[untyped, untyped] values, ?styles: untyped, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil) -> void
936
+ def row(values, styles: nil, height: nil, hidden: false, custom_height: false, outline_level: nil)
484
937
  row_index = @rows.size
485
- cells = Array.new(values.size)
486
- style_lookup = styles.is_a?(Hash) || styles.is_a?(Array)
938
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
939
+ if @strict_excel_mode
940
+ raise ArgumentError, "Row index #{row_index} exceeds Excel limit of 1,048,576 rows" if row_index >= 1_048_576
941
+ raise ArgumentError, "Row height #{height} must be between 0 and 409 points (Excel limitation)" if height && (height.negative? || height > 409)
942
+ end
943
+
944
+ if values.is_a?(Hash)
945
+ max_col = values.keys.map { |k| Elements::Cell.column_index(k) }.max || -1
946
+ cells_array = Array.new(max_col + 1)
947
+ values.each do |k, v|
948
+ idx = Elements::Cell.column_index(k)
949
+ cells_array[idx] = v
950
+ end
951
+ values = cells_array
952
+ end
953
+
954
+ if styles.is_a?(Hash)
955
+ expanded_styles = {}
956
+ styles.each do |k, v|
957
+ if k.is_a?(Range) || k.is_a?(Array)
958
+ k.each { |idx| expanded_styles[Elements::Cell.column_index(idx)] = v }
959
+ else
960
+ expanded_styles[Elements::Cell.column_index(k)] = v
961
+ end
962
+ end
963
+ max_col_style = expanded_styles.keys.max || -1
964
+ styles_array = Array.new(max_col_style + 1)
965
+ expanded_styles.each do |idx, v|
966
+ styles_array[idx] = v
967
+ end
968
+ styles = styles_array
969
+ end
970
+
971
+ # Auto-detect Date / Time for built-in styles
972
+ values.each_with_index do |val, idx|
973
+ cell_style = styles.is_a?(Array) ? styles[idx] : styles
974
+
975
+ if val.is_a?(Date) && cell_style.nil?
976
+ style("__xlsxrb_date", number_format: "yyyy-mm-dd") unless @styles.key?("__xlsxrb_date")
977
+ styles = [] if styles.nil?
978
+ styles = Array.new(values.size, styles) unless styles.is_a?(Array)
979
+ styles[idx] = "__xlsxrb_date"
980
+ elsif val.is_a?(Time) && cell_style.nil?
981
+ style("__xlsxrb_time", number_format: "yyyy-mm-dd hh:mm:ss") unless @styles.key?("__xlsxrb_time")
982
+ styles = [] if styles.nil?
983
+ styles = Array.new(values.size, styles) unless styles.is_a?(Array)
984
+ styles[idx] = "__xlsxrb_time"
985
+ end
986
+ end
987
+
988
+ max_len = values.size
989
+ max_len = [max_len, styles.size].max if styles.is_a?(Array)
990
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
991
+ raise ArgumentError, "Row contains #{max_len} columns, exceeding Excel limit of 16_384 columns" if @strict_excel_mode && max_len > 16_384
992
+
993
+ cells = Array.new(max_len)
994
+ style_lookup = styles.is_a?(Array)
487
995
 
488
996
  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
997
+ while col_index < max_len
998
+ val = col_index < values.size ? values[col_index] : nil
999
+ raise ArgumentError, "Invalid cell value type or value: #{val.class} for value #{val.inspect}" unless val.nil? || val.is_a?(String) || (val.is_a?(Numeric) && !(val.is_a?(Float) && (val.infinite? || val.nan?))) || val.is_a?(TrueClass) || val.is_a?(FalseClass) || val.is_a?(Date) || val.is_a?(Time) || val.is_a?(Elements::Formula) || (val.is_a?(Hash) && val.key?(:formula)) || val.is_a?(Elements::RichText) || (val.is_a?(Array) && val.first.is_a?(Hash) && (val.first.key?(:text) || val.first.key?("text")))
1000
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
1001
+ raise ArgumentError, "Cell text length #{val.length} exceeds Excel limit of 32,767 characters" if @strict_excel_mode && val.is_a?(String) && val.length > 32_767
1002
+
1003
+ if val.is_a?(Array) && val.first.is_a?(Hash) && (val.first.key?(:text) || val.first.key?("text"))
1004
+ # Coerce array of hashes to RichText
1005
+ runs = val.map do |run|
1006
+ text = run[:text] || run["text"]
1007
+ font = run.reject { |k| k.to_s == "text" }
1008
+ { text: text, font: font.empty? ? nil : font }.compact
1009
+ end
1010
+ val = Elements::RichText.new(runs: runs)
1011
+ end
1012
+
1013
+ style_name = if style_lookup
1014
+ col_index < styles.size ? styles[col_index] : nil
1015
+ else
1016
+ styles
1017
+ end
1018
+
1019
+ if style_name.is_a?(Hash)
1020
+ inline_name = "__inline_#{style_name.hash}"
1021
+ style(inline_name, **style_name) unless @styles.key?(inline_name)
1022
+ style_name = inline_name
1023
+ end
1024
+ if val.nil? && style_name.nil?
1025
+ col_index += 1
1026
+ next
1027
+ end
1028
+ # If value is a Formula object or Hash with :formula, store it as the cell's formula
493
1029
  cells[col_index] = if val.is_a?(Elements::Formula)
494
1030
  Elements::Cell.new(
495
1031
  row_index: row_index,
@@ -498,6 +1034,15 @@ module Xlsxrb
498
1034
  formula: val,
499
1035
  style_index: style_name
500
1036
  )
1037
+ elsif val.is_a?(Hash) && val.key?(:formula)
1038
+ f_obj = Elements::Formula.new(val[:formula])
1039
+ Elements::Cell.new(
1040
+ row_index: row_index,
1041
+ column_index: col_index,
1042
+ value: val[:value],
1043
+ formula: f_obj,
1044
+ style_index: style_name
1045
+ )
501
1046
  else
502
1047
  Elements::Cell.new(
503
1048
  row_index: row_index,
@@ -509,6 +1054,7 @@ module Xlsxrb
509
1054
  col_index += 1
510
1055
  end
511
1056
 
1057
+ cells.compact!
512
1058
  @rows << Elements::Row.new(
513
1059
  index: row_index,
514
1060
  cells: cells,
@@ -519,22 +1065,50 @@ module Xlsxrb
519
1065
  )
520
1066
  end
521
1067
 
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
- )
1068
+ # Add a column or multiple columns to the sheet.
1069
+ #
1070
+ # @param index [Integer, String, Range, Array] The column index (0-based), letter, or a collection of them.
1071
+ # @param width [Float, nil] The column width.
1072
+ # @param hidden [Boolean] Whether the column is hidden.
1073
+ # @param custom_width [Boolean] Whether it's a custom width.
1074
+ # @param outline_level [Integer, nil] The outline level.
1075
+ # @note Excel's column width max is 255.
1076
+ # @return [void]
1077
+ # @api public
1078
+ #: (Integer | String | Range[Integer | String] | Array[Integer | String] index, ?width: Float | Integer | nil, ?hidden: bool, ?custom_width: bool, ?outline_level: Integer | nil) -> void
1079
+ def column(index, width: nil, hidden: false, custom_width: false, outline_level: nil)
1080
+ raise ArgumentError, "Column width #{width} must be between 0 and 255 characters (Excel limitation)" if @strict_excel_mode && width && (width.negative? || width > 255)
1081
+
1082
+ indices = case index
1083
+ when Range, Array
1084
+ index.map { |i| Elements::Cell.column_index(i) }
1085
+ else
1086
+ [Elements::Cell.column_index(index)]
1087
+ end
1088
+
1089
+ indices.each do |idx|
1090
+ @columns << Elements::Column.new(
1091
+ index: idx,
1092
+ width: width,
1093
+ hidden: hidden,
1094
+ custom_width: custom_width || !width.nil?,
1095
+ outline_level: outline_level
1096
+ )
1097
+ end
531
1098
  end
532
1099
 
533
1100
  # Add a chart to the sheet.
534
- def add_chart(**options, &block)
1101
+ #
1102
+ # @param options [Hash] Chart options.
1103
+ # @yield [builder]
1104
+ # @yieldparam builder [Xlsxrb::ChartBuilder]
1105
+ # @return [void]
1106
+ # @api public
1107
+ #: (**untyped options) ?{ (ChartBuilder) -> void } -> void
1108
+ def chart(**options)
535
1109
  if block_given?
536
1110
  builder = ChartBuilder.new
537
- block.call(builder)
1111
+ yield builder
538
1112
  options = builder.options.merge(options)
539
1113
  end
540
1114
  @charts << options
@@ -543,7 +1117,16 @@ module Xlsxrb
543
1117
  # --- Hyperlinks ---
544
1118
 
545
1119
  # Add a hyperlink on a cell.
546
- def add_hyperlink(cell, url = nil, display: nil, tooltip: nil, location: nil)
1120
+ #
1121
+ # @param cell [String] The cell reference (e.g. "A1").
1122
+ # @param url [String, nil] The URL.
1123
+ # @param display [String, nil] The display text.
1124
+ # @param tooltip [String, nil] The tooltip.
1125
+ # @param location [String, nil] The internal location reference.
1126
+ # @return [void]
1127
+ # @api public
1128
+ #: (String | Integer cell, ?String? url, ?display: String?, ?tooltip: String?, ?location: String?) -> void
1129
+ def hyperlink(cell, url = nil, display: nil, tooltip: nil, location: nil)
547
1130
  link = { cell: cell }
548
1131
  link[:url] = url if url
549
1132
  link[:display] = display if display
@@ -555,40 +1138,78 @@ module Xlsxrb
555
1138
  # --- Auto Filter / Sort ---
556
1139
 
557
1140
  # Set an auto filter range (e.g. "A1:D10").
558
- # rubocop:disable Naming/AccessorMethodName
559
- def set_auto_filter(range)
1141
+ #
1142
+ # @param range [String] The filter range.
1143
+ # @return [void]
1144
+ # @api public
1145
+ #: (untyped range) -> untyped
1146
+ def auto_filter(range)
560
1147
  @auto_filter = range
561
1148
  end
562
- # rubocop:enable Naming/AccessorMethodName
563
1149
 
564
1150
  # Add a filter column to the auto filter.
565
- def add_filter_column(col_id, filter)
1151
+ #
1152
+ # @param col_id [Integer] 0-based column index within the filter range.
1153
+ # @param filter [Hash] The filter options.
1154
+ # @return [void]
1155
+ # @api public
1156
+ #: (untyped col_id, untyped filter) -> untyped
1157
+ def filter_column(col_id, filter)
566
1158
  @filter_columns[col_id] = filter
567
1159
  end
568
1160
 
569
1161
  # Set sort state.
570
- def set_sort_state(ref, sort_conditions, **opts)
1162
+ #
1163
+ # @param ref [String] The sort range.
1164
+ # @param sort_conditions [Array<Hash>] Sort conditions.
1165
+ # @param opts [Hash] Additional options.
1166
+ # @return [void]
1167
+ # @api public
1168
+ #: (untyped ref, untyped sort_conditions, **untyped opts) -> untyped
1169
+ def sort_state(ref, sort_conditions, **opts)
571
1170
  @sort_state = { ref: ref, sort_conditions: sort_conditions }.merge(opts)
572
1171
  end
573
1172
 
574
1173
  # --- Data Validation ---
575
1174
 
576
1175
  # Add a data validation rule.
577
- def add_data_validation(sqref, **opts)
1176
+ #
1177
+ # @param sqref [String] The cell range (e.g. "A1:A100").
1178
+ # @param opts [Hash] Data validation options.
1179
+ # @return [void]
1180
+ # @api public
1181
+ #: (untyped sqref, **untyped opts) -> untyped
1182
+ def validate_data(sqref, **opts)
578
1183
  @data_validations << opts.merge(sqref: sqref)
579
1184
  end
580
1185
 
581
1186
  # --- Conditional Formatting ---
582
1187
 
583
1188
  # Add a conditional formatting rule.
584
- def add_conditional_format(sqref, **opts)
1189
+ #
1190
+ # @param sqref [String] The cell range.
1191
+ # @param opts [Hash] Conditional format options.
1192
+ # @return [void]
1193
+ # @api public
1194
+ #: (untyped sqref, **untyped opts) -> untyped
1195
+ def conditional_format(sqref, **opts)
585
1196
  @conditional_formats << opts.merge(sqref: sqref)
586
1197
  end
587
1198
 
588
1199
  # --- Tables ---
589
1200
 
590
1201
  # Add a table to the sheet.
591
- def add_table(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
1202
+ #
1203
+ # @param ref [String] The table range.
1204
+ # @param columns [Array<String>] The column names.
1205
+ # @param name [String, nil] The table name.
1206
+ # @param display_name [String, nil] The display name.
1207
+ # @param style [String, nil] The table style.
1208
+ # @param opts [Hash] Additional options.
1209
+ # @return [void]
1210
+ # @api public
1211
+ #: (untyped ref, columns: untyped, ?name: untyped?, ?display_name: untyped?, ?style: untyped?, **untyped opts) -> untyped
1212
+ def table(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
592
1213
  tbl = { ref: ref, columns: columns }
593
1214
  tbl[:name] = name if name
594
1215
  tbl[:display_name] = display_name if display_name
@@ -600,12 +1221,19 @@ module Xlsxrb
600
1221
  # --- Pivot Tables ---
601
1222
 
602
1223
  # 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)
1224
+ #
1225
+ # @param source_ref [String] data source range (e.g. "Sheet1!A1:C10").
1226
+ # @param row_fields [Array<Integer>] array of 0-based field indices for row axis.
1227
+ # @param data_fields [Array<Hash>] array of { fld:, name:, subtotal: } hashes.
1228
+ # @param col_fields [Array<Integer>] array of 0-based field indices for column axis.
1229
+ # @param dest_ref [String] top-left cell for the pivot table (default "E1").
1230
+ # @param name [String, nil] Pivot table name.
1231
+ # @param field_names [Array<String>, nil] Override field names.
1232
+ # @param items [Array, nil] Items configuration.
1233
+ # @return [void]
1234
+ # @api public
1235
+ #: (untyped source_ref, **untyped opts) -> void
1236
+ def pivot_table(source_ref, row_fields:, data_fields:, col_fields: [], dest_ref: "E1", name: nil, field_names: nil, items: nil)
609
1237
  @pivot_tables ||= []
610
1238
  @pivot_tables << {
611
1239
  source_ref: source_ref, row_fields: row_fields,
@@ -618,16 +1246,28 @@ module Xlsxrb
618
1246
  # --- Comments ---
619
1247
 
620
1248
  # Add a comment on a cell.
621
- def add_comment(cell, text, author: "Author")
1249
+ #
1250
+ # @param cell [String] The cell reference.
1251
+ # @param text [String] The comment text.
1252
+ # @param author [String] The author name.
1253
+ # @return [void]
1254
+ # @api public
1255
+ #: (String | Integer cell, String text, ?author: ::String) -> void
1256
+ def comment(cell, text, author: "Author")
622
1257
  @comments << { cell: cell, text: text, author: author }
623
1258
  end
624
1259
 
625
1260
  # --- Sparklines ---
626
1261
 
627
1262
  # 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)
1263
+ #
1264
+ # @param sparklines [Array<Hash>] Array of { data_ref:, location_ref: } hashes.
1265
+ # @param type [String, nil] "line" (default), "column", or "stacked".
1266
+ # @param opts [Hash] Additional options.
1267
+ # @return [void]
1268
+ # @api public
1269
+ #: (**untyped opts) -> void
1270
+ def sparkline_group(sparklines:, type: nil, **opts)
631
1271
  group = { sparklines: sparklines }
632
1272
  group[:type] = type if type
633
1273
  group.merge!(opts)
@@ -636,25 +1276,78 @@ module Xlsxrb
636
1276
 
637
1277
  # --- Merge Cells ---
638
1278
 
639
- # Merge a range of cells (e.g. "A1:B2").
640
- def merge_cells(range)
641
- @merge_cells_ranges << range
1279
+ # Merge a range of cells (e.g. "A1:B2"), or by coordinate indices.
1280
+ #
1281
+ # @param range [String, nil] The string range.
1282
+ # @param row [Integer, nil] Single row index.
1283
+ # @param col_start [Integer, nil] Starting column index.
1284
+ # @param col_end [Integer, nil] Ending column index.
1285
+ # @param row_start [Integer, nil] Starting row index.
1286
+ # @param row_end [Integer, nil] Ending row index.
1287
+ # @return [void]
1288
+ # @api public
1289
+ #: (?(String | Hash[Symbol, Integer | String])? range, ?row: Integer?, ?col_start: (Integer | String)?, ?col_end: (Integer | String)?, ?row_start: Integer?, ?row_end: Integer?) -> void
1290
+ def merge(range = nil, row: nil, col_start: nil, col_end: nil, row_start: nil, row_end: nil)
1291
+ if range.is_a?(Hash)
1292
+ row = range[:row]
1293
+ row_start = range[:row_start]
1294
+ row_end = range[:row_end]
1295
+ col_start = range[:col_start]
1296
+ col_end = range[:col_end]
1297
+ range = nil
1298
+ end
1299
+
1300
+ if range
1301
+ raise ArgumentError, "Invalid merge range format: '#{range}'. Expected format like 'A1:B2'." if @strict_excel_mode && !range.match?(/^[A-Za-z]{1,3}\d+(:[A-Za-z]{1,3}\d+)?$/)
1302
+ return if @merge_cells_ranges.include?(range)
1303
+
1304
+ @merge_cells_ranges << range
1305
+ else
1306
+ r_start = row || row_start || 0
1307
+ r_end = row || row_end || 0
1308
+ c_start = Elements::Cell.column_index(col_start || 0)
1309
+ c_end = Elements::Cell.column_index(col_end || 0)
1310
+ start_ref = "#{Xlsxrb::Elements::Cell.column_letter(c_start)}#{r_start + 1}"
1311
+ end_ref = "#{Xlsxrb::Elements::Cell.column_letter(c_end)}#{r_end + 1}"
1312
+ @merge_cells_ranges << "#{start_ref}:#{end_ref}"
1313
+ end
642
1314
  end
643
1315
 
644
1316
  # --- Freeze / Split Panes ---
645
1317
 
646
1318
  # Freeze panes at the given row and column.
647
- def set_freeze_pane(row: 0, col: 0)
1319
+ #
1320
+ # @param row [Integer] The row index to freeze at (0-based).
1321
+ # @param col [Integer, String] The column index to freeze at (0-based or letter).
1322
+ # @return [void]
1323
+ # @api public
1324
+ #: (?row: Integer, ?col: (Integer | String)) -> void
1325
+ def freeze_pane(row: 0, col: 0)
1326
+ col = Elements::Cell.column_index(col)
648
1327
  @freeze_pane = { row: row, col: col }
649
1328
  end
650
1329
 
651
1330
  # Split panes (non-frozen).
652
- def set_split_pane(x_split: 0, y_split: 0, top_left_cell: nil)
1331
+ #
1332
+ # @param x_split [Integer] X coordinate.
1333
+ # @param y_split [Integer] Y coordinate.
1334
+ # @param top_left_cell [String, nil] Top left cell reference.
1335
+ # @return [void]
1336
+ # @api public
1337
+ #: (?x_split: ::Integer, ?y_split: ::Integer, ?top_left_cell: String?) -> void
1338
+ def split_pane(x_split: 0, y_split: 0, top_left_cell: nil)
653
1339
  @split_pane = { x_split: x_split, y_split: y_split, top_left_cell: top_left_cell }
654
1340
  end
655
1341
 
656
1342
  # Set active cell selection.
657
- def set_selection(active_cell, sqref: nil, pane: nil)
1343
+ #
1344
+ # @param active_cell [String] The active cell reference.
1345
+ # @param sqref [String, nil] The selected range.
1346
+ # @param pane [String, nil] The pane to select in.
1347
+ # @return [void]
1348
+ # @api public
1349
+ #: (String active_cell, ?sqref: String?, ?pane: (String | Symbol)?) -> void
1350
+ def select_cell(active_cell, sqref: nil, pane: nil)
658
1351
  @selection = { active_cell: active_cell, sqref: sqref || active_cell }
659
1352
  @selection[:pane] = pane if pane
660
1353
  end
@@ -662,29 +1355,60 @@ module Xlsxrb
662
1355
  # --- Page Setup / Margins / Print ---
663
1356
 
664
1357
  # Set page margins (in inches).
665
- def set_page_margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
1358
+ #
1359
+ # @param left [Float, nil] Left margin.
1360
+ # @param right [Float, nil] Right margin.
1361
+ # @param top [Float, nil] Top margin.
1362
+ # @param bottom [Float, nil] Bottom margin.
1363
+ # @param header [Float, nil] Header margin.
1364
+ # @param footer [Float, nil] Footer margin.
1365
+ # @return [void]
1366
+ # @api public
1367
+ #: (?left: Float?, ?right: Float?, ?top: Float?, ?bottom: Float?, ?header: Float?, ?footer: Float?) -> void
1368
+ def page_margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
666
1369
  @page_margins = { left: left, right: right, top: top, bottom: bottom, header: header, footer: footer }.compact
667
1370
  end
668
1371
 
669
1372
  # Set page setup properties.
670
- def set_page_setup(**opts)
1373
+ #
1374
+ # @param opts [Hash] Page setup options.
1375
+ # @return [void]
1376
+ # @api public
1377
+ #: (**untyped opts) -> void
1378
+ def page_setup(**opts)
671
1379
  @page_setup.merge!(opts)
672
1380
  end
673
1381
 
674
1382
  # Set header/footer text.
675
- def set_header_footer(**opts)
1383
+ #
1384
+ # @param opts [Hash] Header and footer options.
1385
+ # @return [void]
1386
+ # @api public
1387
+ #: (**untyped opts) -> void
1388
+ def header_footer(**opts)
676
1389
  @header_footer.merge!(opts)
677
1390
  end
678
1391
 
679
1392
  # Set a print option.
680
- def set_print_option(name, value)
1393
+ #
1394
+ # @param name [Symbol] Option name.
1395
+ # @param value [Object] Option value.
1396
+ # @return [void]
1397
+ # @api public
1398
+ #: (Symbol name, untyped value) -> void
1399
+ def print_options(name, value)
681
1400
  @print_options[name] = value
682
1401
  end
683
1402
 
684
1403
  # --- Sheet Protection ---
685
1404
 
686
1405
  # Set sheet protection options.
687
- def set_sheet_protection(**opts)
1406
+ #
1407
+ # @param opts [Hash] Sheet protection options.
1408
+ # @return [void]
1409
+ # @api public
1410
+ #: (**untyped opts) -> void
1411
+ def protect_sheet(**opts)
688
1412
  normalized = opts.dup
689
1413
  plain_password = normalized[:password]
690
1414
  needs_hash = plain_password.is_a?(String) && !plain_password.empty? &&
@@ -701,7 +1425,9 @@ module Xlsxrb
701
1425
  # --- Images ---
702
1426
 
703
1427
  # 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)
1428
+ # @api public
1429
+ #: (String file_data, ?ext: ::String, ?from_col: ::Integer, ?from_row: ::Integer, ?to_col: ::Integer, ?to_row: ::Integer, **untyped opts) -> void
1430
+ def image(file_data, ext: "png", from_col: 0, from_row: 0, to_col: 5, to_row: 10, **opts)
705
1431
  img = { file_data: file_data, ext: ext, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
706
1432
  img.merge!(opts)
707
1433
  @images << img
@@ -710,7 +1436,9 @@ module Xlsxrb
710
1436
  # --- Shapes ---
711
1437
 
712
1438
  # 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)
1439
+ # @api public
1440
+ #: (**untyped opts) -> void
1441
+ def shape(preset: "rect", text: nil, from_col: 0, from_row: 0, to_col: 5, to_row: 5, **opts)
714
1442
  shape = { preset: preset, text: text, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
715
1443
  shape[:name] = opts.delete(:name) || "Shape #{@shapes.size + 1}"
716
1444
  shape.merge!(opts)
@@ -720,27 +1448,38 @@ module Xlsxrb
720
1448
  # --- Sheet Properties ---
721
1449
 
722
1450
  # Set a sheet-level property (e.g. :tab_color).
723
- def set_sheet_property(name, value)
1451
+ # @api public
1452
+ #: (Symbol name, untyped value) -> void
1453
+ def sheet_properties(name, value)
724
1454
  @sheet_properties[name] = value
725
1455
  end
726
1456
 
727
1457
  # Set a sheet view property (e.g. :show_grid_lines, :zoom_scale).
728
- def set_sheet_view(name, value)
1458
+ # @api public
1459
+ #: (Symbol name, untyped value) -> void
1460
+ def sheet_view(name, value)
729
1461
  @sheet_view[name] = value
730
1462
  end
731
1463
 
732
1464
  # --- Row / Column Breaks ---
733
1465
 
734
1466
  # Add a page break before a row.
735
- def add_row_break(row_num)
1467
+ # @api public
1468
+ #: (Integer row_num) -> void
1469
+ def page_break_row(row_num)
736
1470
  @row_breaks << row_num
737
1471
  end
738
1472
 
739
1473
  # Add a page break before a column.
740
- def add_col_break(col_index)
1474
+ # @api public
1475
+ #: (Integer | String col_index) -> void
1476
+ def page_break_col(col_index)
1477
+ col_index = Elements::Cell.column_index(col_index)
741
1478
  @col_breaks << col_index
742
1479
  end
743
1480
 
1481
+ # @api public
1482
+ #: () -> untyped
744
1483
  def build
745
1484
  facade_meta = {}
746
1485
  facade_meta[:hyperlinks] = @hyperlinks unless @hyperlinks.empty?
@@ -776,13 +1515,19 @@ module Xlsxrb
776
1515
  end
777
1516
 
778
1517
  # Internal: returns styles for later processing by WorkbookBuilder
1518
+ #: untyped
779
1519
  attr_reader :styles
780
1520
  end
781
1521
 
782
1522
  # DSL context for Xlsxrb.generate streaming writes.
1523
+ # @api public
783
1524
  class StreamWriter
784
- def initialize(target)
1525
+ attr_reader :current_sheet
1526
+
1527
+ #: (untyped target, ?strict_excel_mode: bool) -> void
1528
+ def initialize(target, strict_excel_mode: true)
785
1529
  @target = target
1530
+ @strict_excel_mode = strict_excel_mode
786
1531
  @sst = []
787
1532
  @sst_index = {}
788
1533
  @sheets = []
@@ -825,13 +1570,35 @@ module Xlsxrb
825
1570
  @app_properties = {}
826
1571
  @custom_properties = []
827
1572
  @workbook_protection = nil
1573
+ @workbook_properties = { update_links: "never" }
1574
+ end
1575
+
1576
+ # Set a workbook property.
1577
+ #
1578
+ # @note **SECURITY WARNING:** If you set `:update_links` to anything other than `"never"`,
1579
+ # you may expose end-users to malicious external reference vulnerabilities (e.g., CSV/DDE Injection)
1580
+ # when they open the generated Excel file. Ensure you fully trust the exported data.
1581
+ #
1582
+ # @param name [Symbol] The property name (e.g. :update_links).
1583
+ # @param value [String, Integer, Boolean] The property value.
1584
+ # @return [void]
1585
+ #: (Symbol name, String | Integer | bool value) -> void
1586
+ def workbook_property(name, value)
1587
+ @workbook_properties[name] = value
828
1588
  end
829
1589
 
830
1590
  # Define a named style that can be applied to cells.
831
- def add_style(name, **opts, &block)
1591
+ #
1592
+ # @param name [String] The name of the style.
1593
+ # @param opts [Hash] Style options (e.g. bold: true).
1594
+ # @yield [style_builder]
1595
+ # @yieldparam style_builder [Xlsxrb::StyleBuilder]
1596
+ # @return [StyleBuilder]
1597
+ #: (String name, **untyped opts) ?{ (StyleBuilder) -> void } -> StyleBuilder
1598
+ def style(name, **opts)
832
1599
  style_builder = StyleBuilder.new(name)
833
1600
  style_builder.apply_options!(**opts) unless opts.empty?
834
- block.call(style_builder) if block_given?
1601
+ yield style_builder if block_given?
835
1602
  @styles[name] = style_builder
836
1603
 
837
1604
  # Register immediately
@@ -840,8 +1607,473 @@ module Xlsxrb
840
1607
  style_builder
841
1608
  end
842
1609
 
843
- # Start or switch to a named sheet.
844
- def add_sheet(name = nil)
1610
+ # Proxy object yielded by the `sheet` method to prevent writing to inactive sheets.
1611
+ # @api public
1612
+ class WorksheetProxy
1613
+ def initialize(writer, sheet_name)
1614
+ @writer = writer
1615
+ @sheet_name = sheet_name
1616
+ end
1617
+
1618
+ # Delegates to StreamWriter#style.
1619
+ # @see StreamWriter#style
1620
+ # @api public
1621
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1622
+ #: (*untyped args, **untyped kwargs) ?{ (Xlsxrb::StyleBuilder) -> void } -> untyped
1623
+ def style(...)
1624
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1625
+
1626
+ @writer.style(...)
1627
+ end
1628
+
1629
+ # Delegates to StreamWriter#merge.
1630
+ # @see StreamWriter#merge
1631
+ # @api public
1632
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1633
+ def merge(...)
1634
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1635
+
1636
+ @writer.merge(...)
1637
+ end
1638
+
1639
+ # Delegates to StreamWriter#shape.
1640
+ # @see StreamWriter#shape
1641
+ # @api public
1642
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1643
+ def shape(...)
1644
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1645
+
1646
+ @writer.shape(...)
1647
+ end
1648
+
1649
+ # Delegates to StreamWriter#internal_sheet_setup.
1650
+ # @see StreamWriter#internal_sheet_setup
1651
+ # @api public
1652
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1653
+ def internal_sheet_setup(...)
1654
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1655
+
1656
+ # simplecov:disable
1657
+ # Edge case / untested delegation block
1658
+ @writer.internal_sheet_setup(...)
1659
+ # simplecov:enable
1660
+ end
1661
+
1662
+ # Delegates to StreamWriter#row.
1663
+ # @see StreamWriter#row
1664
+ # @api public
1665
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1666
+ def row(...)
1667
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1668
+
1669
+ @writer.row(...)
1670
+ end
1671
+
1672
+ # Delegates to StreamWriter#column.
1673
+ # @see StreamWriter#column
1674
+ # @api public
1675
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1676
+ def column(...)
1677
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1678
+
1679
+ @writer.column(...)
1680
+ end
1681
+
1682
+ # Delegates to StreamWriter#chart.
1683
+ # @see StreamWriter#chart
1684
+ # @api public
1685
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1686
+ def chart(...)
1687
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1688
+
1689
+ @writer.chart(...)
1690
+ end
1691
+
1692
+ # Delegates to StreamWriter#hyperlink.
1693
+ # @see StreamWriter#hyperlink
1694
+ # @api public
1695
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1696
+ def hyperlink(...)
1697
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1698
+
1699
+ @writer.hyperlink(...)
1700
+ end
1701
+
1702
+ # Delegates to StreamWriter#auto_filter.
1703
+ # @see StreamWriter#auto_filter
1704
+ # @api public
1705
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1706
+ def auto_filter(...)
1707
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1708
+
1709
+ @writer.auto_filter(...)
1710
+ end
1711
+
1712
+ # Delegates to StreamWriter#filter_column.
1713
+ # @see StreamWriter#filter_column
1714
+ # @api public
1715
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1716
+ def filter_column(...)
1717
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1718
+
1719
+ @writer.filter_column(...)
1720
+ end
1721
+
1722
+ # Delegates to StreamWriter#sort_state.
1723
+ # @see StreamWriter#sort_state
1724
+ # @api public
1725
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1726
+ def sort_state(...)
1727
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1728
+
1729
+ @writer.sort_state(...)
1730
+ end
1731
+
1732
+ # Delegates to StreamWriter#validate_data.
1733
+ # @see StreamWriter#validate_data
1734
+ # @api public
1735
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1736
+ def validate_data(...)
1737
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1738
+
1739
+ @writer.validate_data(...)
1740
+ end
1741
+
1742
+ # Delegates to StreamWriter#conditional_format.
1743
+ # @see StreamWriter#conditional_format
1744
+ # @api public
1745
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1746
+ def conditional_format(...)
1747
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1748
+
1749
+ @writer.conditional_format(...)
1750
+ end
1751
+
1752
+ # Delegates to StreamWriter#table.
1753
+ # @see StreamWriter#table
1754
+ # @api public
1755
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1756
+ def table(...)
1757
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1758
+
1759
+ @writer.table(...)
1760
+ end
1761
+
1762
+ # Delegates to StreamWriter#cleanup!.
1763
+ # @see StreamWriter#cleanup!
1764
+ # @api public
1765
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1766
+ def cleanup!(...)
1767
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1768
+
1769
+ # simplecov:disable
1770
+ # Edge case / untested delegation block
1771
+ @writer.cleanup!(...)
1772
+ # simplecov:enable
1773
+ end
1774
+
1775
+ # Delegates to StreamWriter#comment.
1776
+ # @see StreamWriter#comment
1777
+ # @api public
1778
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1779
+ def comment(...)
1780
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1781
+
1782
+ @writer.comment(...)
1783
+ end
1784
+
1785
+ # Delegates to StreamWriter#pivot_table.
1786
+ # @see StreamWriter#pivot_table
1787
+ # @api public
1788
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1789
+ def pivot_table(...)
1790
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1791
+
1792
+ @writer.pivot_table(...)
1793
+ end
1794
+
1795
+ # Delegates to StreamWriter#sparkline_group.
1796
+ # @see StreamWriter#sparkline_group
1797
+ # @api public
1798
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1799
+ def sparkline_group(...)
1800
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1801
+
1802
+ @writer.sparkline_group(...)
1803
+ end
1804
+
1805
+ # Delegates to StreamWriter#workbook_property.
1806
+ # @see StreamWriter#workbook_property
1807
+ # @api public
1808
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1809
+ def workbook_property(...)
1810
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1811
+
1812
+ @writer.workbook_property(...)
1813
+ end
1814
+
1815
+ # Delegates to StreamWriter#sheet_properties.
1816
+ # @see StreamWriter#sheet_properties
1817
+ # @api public
1818
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1819
+ def sheet_properties(...)
1820
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1821
+
1822
+ @writer.sheet_properties(...)
1823
+ end
1824
+
1825
+ # Delegates to StreamWriter#defined_name.
1826
+ # @see StreamWriter#defined_name
1827
+ # @api public
1828
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1829
+ def defined_name(...)
1830
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1831
+
1832
+ @writer.defined_name(...)
1833
+ end
1834
+
1835
+ # Delegates to StreamWriter#freeze_pane.
1836
+ # @see StreamWriter#freeze_pane
1837
+ # @api public
1838
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1839
+ def freeze_pane(...)
1840
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1841
+
1842
+ @writer.freeze_pane(...)
1843
+ end
1844
+
1845
+ # Delegates to StreamWriter#print_area.
1846
+ # @see StreamWriter#print_area
1847
+ # @api public
1848
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1849
+ def print_area(...)
1850
+ # simplecov:disable
1851
+ # Edge case / untested delegation block
1852
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1853
+
1854
+ @writer.print_area(...)
1855
+ # simplecov:enable
1856
+ end
1857
+
1858
+ # Delegates to StreamWriter#print_titles.
1859
+ # @see StreamWriter#print_titles
1860
+ # @api public
1861
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1862
+ def print_titles(...)
1863
+ # simplecov:disable
1864
+ # Edge case / untested delegation block
1865
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1866
+
1867
+ @writer.print_titles(...)
1868
+ # simplecov:enable
1869
+ end
1870
+
1871
+ # Delegates to StreamWriter#split_pane.
1872
+ # @see StreamWriter#split_pane
1873
+ # @api public
1874
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1875
+ def split_pane(...)
1876
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1877
+
1878
+ @writer.split_pane(...)
1879
+ end
1880
+
1881
+ # Delegates to StreamWriter#protect_workbook.
1882
+ # @see StreamWriter#protect_workbook
1883
+ # @api public
1884
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1885
+ def protect_workbook(...)
1886
+ # simplecov:disable
1887
+ # Edge case / untested delegation block
1888
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1889
+
1890
+ @writer.protect_workbook(...)
1891
+ # simplecov:enable
1892
+ end
1893
+
1894
+ # Delegates to StreamWriter#core_property.
1895
+ # @see StreamWriter#core_property
1896
+ # @api public
1897
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1898
+ def core_property(...)
1899
+ # simplecov:disable
1900
+ # Edge case / untested delegation block
1901
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1902
+
1903
+ @writer.core_property(...)
1904
+ # simplecov:enable
1905
+ end
1906
+
1907
+ # Delegates to StreamWriter#select_cell.
1908
+ # @see StreamWriter#select_cell
1909
+ # @api public
1910
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1911
+ def select_cell(...)
1912
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1913
+
1914
+ @writer.select_cell(...)
1915
+ end
1916
+
1917
+ # Delegates to StreamWriter#page_margins.
1918
+ # @see StreamWriter#page_margins
1919
+ # @api public
1920
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1921
+ def page_margins(...)
1922
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1923
+
1924
+ @writer.page_margins(...)
1925
+ end
1926
+
1927
+ # Delegates to StreamWriter#page_setup.
1928
+ # @see StreamWriter#page_setup
1929
+ # @api public
1930
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1931
+ def page_setup(...)
1932
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1933
+
1934
+ @writer.page_setup(...)
1935
+ end
1936
+
1937
+ # Delegates to StreamWriter#header_footer.
1938
+ # @see StreamWriter#header_footer
1939
+ # @api public
1940
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1941
+ def header_footer(...)
1942
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1943
+
1944
+ @writer.header_footer(...)
1945
+ end
1946
+
1947
+ # Delegates to StreamWriter#print_options.
1948
+ # @see StreamWriter#print_options
1949
+ # @api public
1950
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1951
+ def print_options(...)
1952
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1953
+
1954
+ @writer.print_options(...)
1955
+ end
1956
+
1957
+ # Delegates to StreamWriter#properties.
1958
+ # @see StreamWriter#properties
1959
+ # @api public
1960
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1961
+ def properties(...)
1962
+ # simplecov:disable
1963
+ # Edge case / untested delegation block
1964
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1965
+
1966
+ @writer.properties(...)
1967
+ # simplecov:enable
1968
+ end
1969
+
1970
+ # Delegates to StreamWriter#app_property.
1971
+ # @see StreamWriter#app_property
1972
+ # @api public
1973
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1974
+ def app_property(...)
1975
+ # simplecov:disable
1976
+ # Edge case / untested delegation block
1977
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1978
+
1979
+ @writer.app_property(...)
1980
+ # simplecov:enable
1981
+ end
1982
+
1983
+ # Delegates to StreamWriter#protect_sheet.
1984
+ # @see StreamWriter#protect_sheet
1985
+ # @api public
1986
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1987
+ def protect_sheet(...)
1988
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
1989
+
1990
+ @writer.protect_sheet(...)
1991
+ end
1992
+
1993
+ # Delegates to StreamWriter#custom_property.
1994
+ # @see StreamWriter#custom_property
1995
+ # @api public
1996
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
1997
+ def custom_property(...)
1998
+ # simplecov:disable
1999
+ # Edge case / untested delegation block
2000
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
2001
+
2002
+ @writer.custom_property(...)
2003
+ # simplecov:enable
2004
+ end
2005
+
2006
+ # Delegates to StreamWriter#image.
2007
+ # @see StreamWriter#image
2008
+ # @api public
2009
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
2010
+ def image(...)
2011
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
2012
+
2013
+ @writer.image(...)
2014
+ end
2015
+
2016
+ # Delegates to StreamWriter#sheet_view.
2017
+ # @see StreamWriter#sheet_view
2018
+ # @api public
2019
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
2020
+ def sheet_view(...)
2021
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
2022
+
2023
+ @writer.sheet_view(...)
2024
+ end
2025
+
2026
+ # Delegates to StreamWriter#page_break_row.
2027
+ # @see StreamWriter#page_break_row
2028
+ # @api public
2029
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
2030
+ def page_break_row(...)
2031
+ # simplecov:disable
2032
+ # Edge case / untested delegation block
2033
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
2034
+
2035
+ @writer.page_break_row(...)
2036
+ # simplecov:enable
2037
+ end
2038
+
2039
+ # Delegates to StreamWriter#page_break_col.
2040
+ # @see StreamWriter#page_break_col
2041
+ # @api public
2042
+ #: (*untyped args, **untyped kwargs) ?{ (*untyped) -> untyped } -> untyped
2043
+ def page_break_col(...)
2044
+ # simplecov:disable
2045
+ # Edge case / untested delegation block
2046
+ raise Error, "Sheet '' is no longer active. In streaming mode, you cannot write to a previous sheet." if @writer.current_sheet != @sheet_name
2047
+
2048
+ @writer.page_break_col(...)
2049
+ # simplecov:enable
2050
+ end
2051
+ end
2052
+
2053
+ # Add a new sheet.
2054
+ #
2055
+ # @param name [String, nil] The name of the sheet.
2056
+ # @param opts [Hash] Sheet properties.
2057
+ # @yield [sheet_builder]
2058
+ # @yieldparam sheet_builder [Xlsxrb::WorksheetBuilder]
2059
+ # @return [void]
2060
+ #: (?String? name, **untyped opts) ?{ (WorksheetProxy) -> void } -> untyped
2061
+ def sheet(name = nil, **opts)
2062
+ name ||= "Sheet#{@sheets.size + 1}"
2063
+ raise ArgumentError, "Sheet name '#{name}' must be <= 31 characters (Excel limitation)" if @strict_excel_mode && name.length > 31
2064
+ raise ArgumentError, "Sheet name '#{name}' contains invalid characters (ECMA-376 OOXML specification)" if name.match?(%r{[\[\]*?/\\]})
2065
+ raise ArgumentError, "Sheet name '#{name}' is already used. Excel requires unique sheet names." if @strict_excel_mode && @sheets.map { |s| s.respond_to?(:name) ? s.name.downcase : s.to_s.downcase }.include?(name.downcase)
2066
+
2067
+ internal_sheet_setup(name)
2068
+ opts.each { |k, v| set_sheet_property(k, v) }
2069
+
2070
+ yield WorksheetProxy.new(self, @current_sheet) if block_given?
2071
+ @current_sheet
2072
+ end
2073
+
2074
+ # Internal: Start or switch to a named sheet (internal helper).
2075
+ #: (?String? name) ?{ (WorksheetProxy) -> void } -> (WorksheetProxy | nil)
2076
+ def internal_sheet_setup(name = nil)
845
2077
  flush_current_sheet
846
2078
  name ||= "Sheet#{@sheets.size + 1}"
847
2079
  @current_sheet = name
@@ -882,25 +2114,105 @@ module Xlsxrb
882
2114
 
883
2115
  return unless block_given?
884
2116
 
2117
+ # simplecov:disable
2118
+ # Edge case / untested delegation block
885
2119
  yield self
886
2120
  flush_current_sheet
2121
+ # simplecov:enable
887
2122
  end
888
2123
 
889
2124
  # Add a row of values. values is an Array.
890
2125
  # 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?
2126
+ # Add a row to the sheet.
2127
+ #
2128
+ # @param values [Array, Hash] The cell values.
2129
+ # @param styles [String, Array<String>, nil] Styles to apply to cells.
2130
+ # @param height [Float, nil] The row height.
2131
+ # @param hidden [Boolean] Whether the row is hidden.
2132
+ # @param custom_height [Boolean] Whether it's a custom height.
2133
+ # @param outline_level [Integer, nil] The outline level.
2134
+ # @note Excel's column limit is 16,384, row limit is 1,048,576, string max length is 32,767.
2135
+ # @return [void]
2136
+ #: (Array[untyped] | Hash[untyped, untyped] values, ?styles: untyped, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil) -> void
2137
+ def row(values, styles: nil, height: nil, hidden: false, custom_height: false, outline_level: nil)
2138
+ sheet if @current_sheet.nil?
893
2139
 
894
2140
  row_index = @current_row_index
2141
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
2142
+ if @strict_excel_mode
2143
+ raise ArgumentError, "Row index #{row_index} exceeds Excel limit of 1,048,576 rows" if row_index >= 1_048_576
2144
+ raise ArgumentError, "Row height #{height} must be between 0 and 409 points (Excel limitation)" if height && (height.negative? || height > 409)
2145
+ end
895
2146
  @current_row_index += 1
896
2147
 
897
- @current_cells ||= {}
898
- row_num = row_index + 1
899
- values.each_with_index do |val, col_idx|
900
- next if val.nil?
2148
+ if values.is_a?(Hash)
2149
+ max_col = values.keys.map { |k| Elements::Cell.column_index(k) }.max || -1
2150
+ cells_array = Array.new(max_col + 1)
2151
+ values.each do |k, v|
2152
+ idx = Elements::Cell.column_index(k)
2153
+ cells_array[idx] = v
2154
+ end
2155
+ values = cells_array
2156
+ end
2157
+
2158
+ if styles.is_a?(Hash)
2159
+ expanded_styles = {}
2160
+ styles.each do |k, v|
2161
+ if k.is_a?(Range) || k.is_a?(Array)
2162
+ k.each { |idx| expanded_styles[Elements::Cell.column_index(idx)] = v }
2163
+ else
2164
+ expanded_styles[Elements::Cell.column_index(k)] = v
2165
+ end
2166
+ end
2167
+ max_col_style = expanded_styles.keys.max || -1
2168
+ styles_array = Array.new(max_col_style + 1)
2169
+ expanded_styles.each do |idx, v|
2170
+ styles_array[idx] = v
2171
+ end
2172
+ styles = styles_array
2173
+ end
2174
+
2175
+ # Auto-detect Date / Time for built-in styles
2176
+ values.each_with_index do |val, idx|
2177
+ cell_style = styles.is_a?(Array) ? styles[idx] : styles
2178
+
2179
+ if val.is_a?(Date) && cell_style.nil?
2180
+ style("__xlsxrb_date", number_format: "yyyy-mm-dd") unless @styles.key?("__xlsxrb_date")
2181
+ styles = [] if styles.nil?
2182
+ styles = Array.new(values.size, styles) unless styles.is_a?(Array)
2183
+ styles[idx] = "__xlsxrb_date"
2184
+ elsif val.is_a?(Time) && cell_style.nil?
2185
+ style("__xlsxrb_time", number_format: "yyyy-mm-dd hh:mm:ss") unless @styles.key?("__xlsxrb_time")
2186
+ styles = [] if styles.nil?
2187
+ styles = Array.new(values.size, styles) unless styles.is_a?(Array)
2188
+ styles[idx] = "__xlsxrb_time"
2189
+ end
2190
+ end
2191
+
2192
+ has_charts = @current_charts && !@current_charts.empty?
2193
+ row_num = row_index + 1 if has_charts
2194
+
2195
+ max_len = values.size
2196
+ max_len = [max_len, styles.size].max if styles.is_a?(Array)
2197
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
2198
+ raise ArgumentError, "Row contains #{max_len} columns, exceeding Excel limit of 16_384 columns" if @strict_excel_mode && max_len > 16_384
2199
+
2200
+ if has_charts || @strict_excel_mode
2201
+ @current_cells ||= {} if has_charts
2202
+ max_len.times do |col_idx|
2203
+ val = col_idx < values.size ? values[col_idx] : nil
2204
+ next if val.nil?
901
2205
 
902
- addr = "#{Elements::Cell.column_letter(col_idx)}#{row_num}"
903
- @current_cells[addr] = val
2206
+ raise ArgumentError, "Invalid cell value type or value: #{val.class} for value #{val.inspect}" unless val.nil? || val.is_a?(String) || (val.is_a?(Numeric) && !(val.is_a?(Float) && (val.infinite? || val.nan?))) || val.is_a?(TrueClass) || val.is_a?(FalseClass) || val.is_a?(Date) || val.is_a?(Time) || val.is_a?(Elements::Formula) || (val.is_a?(Hash) && val.key?(:formula)) || val.is_a?(Elements::RichText) || val.is_a?(Elements::CellError)
2207
+
2208
+ # See: https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3
2209
+ raise ArgumentError, "Cell text length #{val.length} exceeds Excel limit of 32,767 characters" if @strict_excel_mode && val.is_a?(String) && val.length > 32_767
2210
+
2211
+ if has_charts
2212
+ addr = "#{Elements::Cell.column_letter(col_idx)}#{row_num}"
2213
+ @current_cells[addr] = val
2214
+ end
2215
+ end
904
2216
  end
905
2217
 
906
2218
  attrs = nil
@@ -916,19 +2228,41 @@ module Xlsxrb
916
2228
  end
917
2229
 
918
2230
  # 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 }
2231
+ # Add a column to the sheet.
2232
+ #
2233
+ # @param index [Integer, String] The column index (0-based) or letter.
2234
+ # @param width [Float, nil] The column width.
2235
+ # @param hidden [Boolean] Whether the column is hidden.
2236
+ # @param custom_width [Boolean] Whether it's a custom width.
2237
+ # @param outline_level [Integer, nil] The outline level.
2238
+ # @note Excel's column width max is 255.
2239
+ # @return [void]
2240
+ #: (Integer | String | Range[Integer | String] | Array[Integer | String] index, ?width: Float | Integer | nil, ?hidden: bool, ?custom_width: bool, ?outline_level: Integer | nil) -> void
2241
+ def column(index, width: nil, hidden: false, custom_width: false, outline_level: nil)
2242
+ raise ArgumentError, "Column width #{width} must be between 0 and 255 characters (Excel limitation)" if @strict_excel_mode && width && (width.negative? || width > 255)
2243
+
2244
+ indices = case index
2245
+ when Range, Array
2246
+ index.map { |i| Elements::Cell.column_index(i) }
2247
+ else
2248
+ [Elements::Cell.column_index(index)]
2249
+ end
2250
+
2251
+ sheet if @current_sheet.nil?
2252
+
2253
+ indices.each do |idx|
2254
+ @current_columns << { index: idx, width: width, hidden: hidden, custom_width: custom_width || !width.nil?, outline_level: outline_level }
2255
+ end
923
2256
  end
924
2257
 
925
2258
  # Add a chart to the current sheet.
926
- def add_chart(**options, &block)
927
- add_sheet if @current_sheet.nil?
2259
+ #: (**untyped options) ?{ (ChartBuilder) -> void } -> void
2260
+ def chart(**options)
2261
+ sheet if @current_sheet.nil?
928
2262
 
929
2263
  if block_given?
930
2264
  builder = ChartBuilder.new
931
- block.call(builder)
2265
+ yield builder
932
2266
  options = builder.options.merge(options)
933
2267
  end
934
2268
 
@@ -936,9 +2270,9 @@ module Xlsxrb
936
2270
  end
937
2271
 
938
2272
  # --- Hyperlinks ---
939
-
940
- def add_hyperlink(cell, url = nil, display: nil, tooltip: nil, location: nil)
941
- add_sheet if @current_sheet.nil?
2273
+ #: (String | Integer cell, ?String? url, ?display: String?, ?tooltip: String?, ?location: String?) -> void
2274
+ def hyperlink(cell, url = nil, display: nil, tooltip: nil, location: nil)
2275
+ sheet if @current_sheet.nil?
942
2276
  link = { cell: cell }
943
2277
  link[:url] = url if url
944
2278
  link[:display] = display if display
@@ -948,42 +2282,42 @@ module Xlsxrb
948
2282
  end
949
2283
 
950
2284
  # --- Auto Filter / Sort ---
951
-
952
- # rubocop:disable Naming/AccessorMethodName
953
- def set_auto_filter(range)
954
- add_sheet if @current_sheet.nil?
2285
+ #: (String range) -> void
2286
+ def auto_filter(range)
2287
+ sheet if @current_sheet.nil?
955
2288
  @current_auto_filter = range
956
2289
  end
957
- # rubocop:enable Naming/AccessorMethodName
958
2290
 
959
- def add_filter_column(col_id, filter)
960
- add_sheet if @current_sheet.nil?
2291
+ #: (untyped col_id, untyped filter) -> untyped
2292
+ def filter_column(col_id, filter)
2293
+ sheet if @current_sheet.nil?
961
2294
  @current_filter_columns[col_id] = filter
962
2295
  end
963
2296
 
964
- def set_sort_state(ref, sort_conditions, **opts)
965
- add_sheet if @current_sheet.nil?
2297
+ #: (untyped ref, untyped sort_conditions, **untyped opts) -> untyped
2298
+ def sort_state(ref, sort_conditions, **opts)
2299
+ sheet if @current_sheet.nil?
966
2300
  @current_sort_state = { ref: ref, sort_conditions: sort_conditions }.merge(opts)
967
2301
  end
968
2302
 
969
2303
  # --- Data Validation ---
970
-
971
- def add_data_validation(sqref, **opts)
972
- add_sheet if @current_sheet.nil?
2304
+ #: (untyped sqref, **untyped opts) -> void
2305
+ def validate_data(sqref, **opts)
2306
+ sheet if @current_sheet.nil?
973
2307
  @current_data_validations << opts.merge(sqref: sqref)
974
2308
  end
975
2309
 
976
2310
  # --- Conditional Formatting ---
977
-
978
- def add_conditional_format(sqref, **opts)
979
- add_sheet if @current_sheet.nil?
2311
+ #: (untyped sqref, **untyped opts) -> void
2312
+ def conditional_format(sqref, **opts)
2313
+ sheet if @current_sheet.nil?
980
2314
  @current_conditional_formats << opts.merge(sqref: sqref)
981
2315
  end
982
2316
 
983
2317
  # --- Tables ---
984
-
985
- def add_table(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
986
- add_sheet if @current_sheet.nil?
2318
+ #: (untyped ref, **untyped opts) -> void
2319
+ def table(ref, columns:, name: nil, display_name: nil, style: nil, **opts)
2320
+ sheet if @current_sheet.nil?
987
2321
  tbl = { ref: ref, columns: columns }
988
2322
  tbl[:name] = name if name
989
2323
  tbl[:display_name] = display_name if display_name
@@ -993,9 +2327,9 @@ module Xlsxrb
993
2327
  end
994
2328
 
995
2329
  # --- 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?
2330
+ #: (untyped source_ref, **untyped opts) -> void
2331
+ def pivot_table(source_ref, row_fields:, data_fields:, col_fields: [], dest_ref: "E1", name: nil, field_names: nil, items: nil)
2332
+ sheet if @current_sheet.nil?
999
2333
  @current_pivot_tables ||= []
1000
2334
  @current_pivot_tables << {
1001
2335
  source_ref: source_ref, row_fields: row_fields,
@@ -1006,73 +2340,114 @@ module Xlsxrb
1006
2340
  end
1007
2341
 
1008
2342
  # --- Comments ---
1009
-
1010
- def add_comment(cell, text, author: "Author")
1011
- add_sheet if @current_sheet.nil?
2343
+ #: (String | Integer cell, String text, ?author: ::String) -> void
2344
+ def comment(cell, text, author: "Author")
2345
+ sheet if @current_sheet.nil?
1012
2346
  @current_comments << { cell: cell, text: text, author: author }
1013
2347
  end
1014
2348
 
1015
2349
  # --- Sparklines ---
1016
-
1017
- def add_sparkline_group(sparklines:, type: nil, **opts)
1018
- add_sheet if @current_sheet.nil?
2350
+ #: (**untyped opts) -> void
2351
+ def sparkline_group(sparklines:, type: nil, **opts)
2352
+ sheet if @current_sheet.nil?
1019
2353
  group = { sparklines: sparklines }
1020
2354
  group[:type] = type if type
1021
2355
  group.merge!(opts)
1022
2356
  @current_sparkline_groups << group
1023
2357
  end
1024
2358
 
1025
- # --- Merge Cells ---
2359
+ # Merge a range of cells (e.g. "A1:B2"), or by coordinate indices.
2360
+ #
2361
+ # @param range [String, nil] The string range.
2362
+ # @param row [Integer, nil] Single row index.
2363
+ # @param col_start [Integer, nil] Starting column index.
2364
+ # @param col_end [Integer, nil] Ending column index.
2365
+ # @param row_start [Integer, nil] Starting row index.
2366
+ # @param row_end [Integer, nil] Ending row index.
2367
+ # @return [void]
2368
+ #: (?(String | Hash[Symbol, Integer | String])? range, ?row: Integer?, ?col_start: (Integer | String)?, ?col_end: (Integer | String)?, ?row_start: Integer?, ?row_end: Integer?) -> void
2369
+ def merge(range = nil, row: nil, col_start: nil, col_end: nil, row_start: nil, row_end: nil)
2370
+ sheet if @current_sheet.nil?
2371
+ if range.is_a?(Hash)
2372
+ row = range[:row]
2373
+ row_start = range[:row_start]
2374
+ row_end = range[:row_end]
2375
+ col_start = range[:col_start]
2376
+ col_end = range[:col_end]
2377
+ range = nil
2378
+ end
1026
2379
 
1027
- def merge_cells(range)
1028
- add_sheet if @current_sheet.nil?
1029
- @current_merge_cells << range
2380
+ if range
2381
+ raise ArgumentError, "Invalid merge range format: '#{range}'. Expected format like 'A1:B2'." if @strict_excel_mode && !range.match?(/^[A-Za-z]{1,3}\d+(:[A-Za-z]{1,3}\d+)?$/)
2382
+
2383
+ @current_merge_cells << range
2384
+ else
2385
+ r_start = row || row_start || 0
2386
+ r_end = row || row_end || 0
2387
+ c_start = Elements::Cell.column_index(col_start || 0)
2388
+ c_end = Elements::Cell.column_index(col_end || 0)
2389
+ start_ref = "#{Xlsxrb::Elements::Cell.column_letter(c_start)}#{r_start + 1}"
2390
+ end_ref = "#{Xlsxrb::Elements::Cell.column_letter(c_end)}#{r_end + 1}"
2391
+ @current_merge_cells << "#{start_ref}:#{end_ref}"
2392
+ end
1030
2393
  end
1031
2394
 
1032
2395
  # --- Freeze / Split Panes ---
1033
2396
 
1034
- def set_freeze_pane(row: 0, col: 0)
1035
- add_sheet if @current_sheet.nil?
2397
+ # Freeze panes at the given row and column.
2398
+ #
2399
+ # @param row [Integer] The row index to freeze at (0-based).
2400
+ # @param col [Integer, String] The column index to freeze at (0-based or letter).
2401
+ # @return [void]
2402
+ #: (?row: Integer, ?col: (Integer | String)) -> void
2403
+ def freeze_pane(row: 0, col: 0)
2404
+ col = Elements::Cell.column_index(col)
2405
+ sheet if @current_sheet.nil?
1036
2406
  @current_freeze_pane = { row: row, col: col }
1037
2407
  end
1038
2408
 
1039
- def set_split_pane(x_split: 0, y_split: 0, top_left_cell: nil)
1040
- add_sheet if @current_sheet.nil?
2409
+ #: (?x_split: ::Integer, ?y_split: ::Integer, ?top_left_cell: String?) -> void
2410
+ def split_pane(x_split: 0, y_split: 0, top_left_cell: nil)
2411
+ sheet if @current_sheet.nil?
1041
2412
  @current_split_pane = { x_split: x_split, y_split: y_split, top_left_cell: top_left_cell }
1042
2413
  end
1043
2414
 
1044
- def set_selection(active_cell, sqref: nil, pane: nil)
1045
- add_sheet if @current_sheet.nil?
2415
+ #: (String active_cell, ?sqref: String?, ?pane: (String | Symbol)?) -> void
2416
+ def select_cell(active_cell, sqref: nil, pane: nil)
2417
+ sheet if @current_sheet.nil?
1046
2418
  @current_selection = { active_cell: active_cell, sqref: sqref || active_cell }
1047
2419
  @current_selection[:pane] = pane if pane
1048
2420
  end
1049
2421
 
1050
2422
  # --- 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?
2423
+ #: (?left: Float?, ?right: Float?, ?top: Float?, ?bottom: Float?, ?header: Float?, ?footer: Float?) -> void
2424
+ def page_margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
2425
+ sheet if @current_sheet.nil?
1054
2426
  @current_page_margins = { left: left, right: right, top: top, bottom: bottom, header: header, footer: footer }.compact
1055
2427
  end
1056
2428
 
1057
- def set_page_setup(**opts)
1058
- add_sheet if @current_sheet.nil?
2429
+ #: (**untyped opts) -> void
2430
+ def page_setup(**opts)
2431
+ sheet if @current_sheet.nil?
1059
2432
  @current_page_setup.merge!(opts)
1060
2433
  end
1061
2434
 
1062
- def set_header_footer(**opts)
1063
- add_sheet if @current_sheet.nil?
2435
+ #: (**untyped opts) -> void
2436
+ def header_footer(**opts)
2437
+ sheet if @current_sheet.nil?
1064
2438
  @current_header_footer.merge!(opts)
1065
2439
  end
1066
2440
 
1067
- def set_print_option(name, value)
1068
- add_sheet if @current_sheet.nil?
2441
+ #: (Symbol name, untyped value) -> void
2442
+ def print_options(name, value)
2443
+ sheet if @current_sheet.nil?
1069
2444
  @current_print_options[name] = value
1070
2445
  end
1071
2446
 
1072
2447
  # --- Sheet Protection ---
1073
-
1074
- def set_sheet_protection(**opts)
1075
- add_sheet if @current_sheet.nil?
2448
+ #: (**untyped opts) -> void
2449
+ def protect_sheet(**opts)
2450
+ sheet if @current_sheet.nil?
1076
2451
  normalized = opts.dup
1077
2452
  plain_password = normalized[:password]
1078
2453
  needs_hash = plain_password.is_a?(String) && !plain_password.empty? &&
@@ -1087,18 +2462,18 @@ module Xlsxrb
1087
2462
  end
1088
2463
 
1089
2464
  # --- 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?
2465
+ #: (String file_data, ?ext: ::String, ?from_col: ::Integer, ?from_row: ::Integer, ?to_col: ::Integer, ?to_row: ::Integer, **untyped opts) -> void
2466
+ def image(file_data, ext: "png", from_col: 0, from_row: 0, to_col: 5, to_row: 10, **opts)
2467
+ sheet if @current_sheet.nil?
1093
2468
  img = { file_data: file_data, ext: ext, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
1094
2469
  img.merge!(opts)
1095
2470
  @current_images << img
1096
2471
  end
1097
2472
 
1098
2473
  # --- 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?
2474
+ #: (**untyped opts) -> void
2475
+ def shape(preset: "rect", text: nil, from_col: 0, from_row: 0, to_col: 5, to_row: 5, **opts)
2476
+ sheet if @current_sheet.nil?
1102
2477
  shape = { preset: preset, text: text, from_col: from_col, from_row: from_row, to_col: to_col, to_row: to_row }
1103
2478
  shape[:name] = opts.delete(:name) || "Shape #{@current_shapes.size + 1}"
1104
2479
  shape.merge!(opts)
@@ -1106,33 +2481,49 @@ module Xlsxrb
1106
2481
  end
1107
2482
 
1108
2483
  # --- Sheet Properties ---
1109
-
1110
- def set_sheet_property(name, value)
1111
- add_sheet if @current_sheet.nil?
2484
+ #: (Symbol name, untyped value) -> void
2485
+ def sheet_properties(name, value)
2486
+ sheet if @current_sheet.nil?
1112
2487
  @current_sheet_properties[name] = value
1113
2488
  end
1114
2489
 
1115
- def set_sheet_view(name, value)
1116
- add_sheet if @current_sheet.nil?
2490
+ #: (Symbol name, untyped value) -> void
2491
+ def sheet_view(name, value)
2492
+ sheet if @current_sheet.nil?
1117
2493
  @current_sheet_view[name] = value
1118
2494
  end
1119
2495
 
1120
2496
  # --- Row / Column Breaks ---
1121
-
1122
- def add_row_break(row_num)
1123
- add_sheet if @current_sheet.nil?
2497
+ #: (Integer row_num) -> void
2498
+ def page_break_row(row_num)
2499
+ # simplecov:disable
2500
+ # Edge case / untested delegation block
2501
+ sheet if @current_sheet.nil?
1124
2502
  @current_row_breaks << row_num
2503
+ # simplecov:enable
1125
2504
  end
1126
2505
 
1127
- def add_col_break(col_index)
1128
- add_sheet if @current_sheet.nil?
2506
+ #: (Integer col_index) -> void
2507
+ def page_break_col(col_index)
2508
+ # simplecov:disable
2509
+ # Edge case / untested delegation block
2510
+ col_index = Elements::Cell.column_index(col_index)
2511
+ sheet if @current_sheet.nil?
1129
2512
  @current_col_breaks << col_index
2513
+ # simplecov:enable
1130
2514
  end
1131
2515
 
1132
2516
  # --- Workbook-Level Methods ---
1133
2517
 
1134
2518
  # Add a defined name.
1135
- def add_defined_name(name, value, sheet: nil, hidden: false)
2519
+ #
2520
+ # @param name [String] The defined name.
2521
+ # @param value [String] The formula or value.
2522
+ # @param sheet [String, nil] Local sheet name.
2523
+ # @param hidden [Boolean] Whether the defined name is hidden.
2524
+ # @return [void]
2525
+ #: (String name, String value, ?sheet: String?, ?hidden: bool) -> void
2526
+ def defined_name(name, value, sheet: nil, hidden: false)
1136
2527
  entry = { name: name, value: value, hidden: hidden }
1137
2528
  if sheet
1138
2529
  # local_sheet_id will be resolved at close time
@@ -1142,54 +2533,102 @@ module Xlsxrb
1142
2533
  end
1143
2534
 
1144
2535
  # Set the print area for the current or named sheet.
1145
- def set_print_area(range, sheet: nil)
2536
+ #: (String range, ?sheet: String?) -> void
2537
+ def print_area(range, sheet: nil)
2538
+ # simplecov:disable
2539
+ # Edge case / untested delegation block
1146
2540
  sheet_name = sheet || @current_sheet || "Sheet1"
1147
2541
  value = "'#{sheet_name}'!#{absolute_range(range)}"
1148
2542
  @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)
2543
+ defined_name("_xlnm.Print_Area", value, sheet: sheet_name)
2544
+ # simplecov:enable
1150
2545
  end
1151
2546
 
1152
2547
  # Set print titles for the current or named sheet.
1153
- def set_print_titles(rows: nil, cols: nil, sheet: nil)
2548
+ #: (?rows: String?, ?cols: String?, ?sheet: String?) -> void
2549
+ def print_titles(rows: nil, cols: nil, sheet: nil)
1154
2550
  sheet_name = sheet || @current_sheet || "Sheet1"
1155
2551
  parts = []
1156
2552
  parts << "'#{sheet_name}'!$#{cols.sub(":", ":$")}" if cols
1157
2553
  parts << "'#{sheet_name}'!$#{rows.sub(":", ":$")}" if rows
1158
2554
  value = parts.join(",")
1159
2555
  @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)
2556
+ defined_name("_xlnm.Print_Titles", value, sheet: sheet_name)
1161
2557
  end
1162
2558
 
1163
2559
  # Set workbook protection.
1164
- def set_workbook_protection(**opts)
2560
+ #
2561
+ # @param opts [Hash] Protection options.
2562
+ # @return [void]
2563
+ #: (**String | Integer | bool | nil opts) -> void
2564
+ def protect_workbook(**opts)
1165
2565
  @workbook_protection = opts
1166
2566
  end
1167
2567
 
1168
2568
  # Set a core document property.
1169
- def set_core_property(name, value)
2569
+ #
2570
+ # @param name [Symbol] The property name.
2571
+ # @param value [String, Integer, Time] The property value.
2572
+ # @return [void]
2573
+ #: (Symbol name, String | Integer | Time value) -> void
2574
+ def core_property(name, value)
1170
2575
  @core_properties[name] = value
1171
2576
  end
1172
2577
 
1173
2578
  # Set an app document property.
1174
- def set_app_property(name, value)
2579
+ #
2580
+ # @param name [Symbol] The property name.
2581
+ # @param value [String, Integer, Time] The property value.
2582
+ # @return [void]
2583
+ #: (Symbol name, String | Integer | Time value) -> void
2584
+ def app_property(name, value)
2585
+ # simplecov:disable
2586
+ # Edge case / untested delegation block
1175
2587
  @app_properties[name] = value
2588
+ # simplecov:enable
2589
+ end
2590
+
2591
+ # Set multiple core and/or app properties.
2592
+ #
2593
+ # @param core [Hash, nil] Core properties.
2594
+ # @param app [Hash, nil] App properties.
2595
+ # @return [void]
2596
+ #: (?core: Hash[Symbol, String | Integer | Time]?, ?app: Hash[Symbol, String | Integer | Time]?) -> void
2597
+ def properties(core: nil, app: nil)
2598
+ # simplecov:disable
2599
+ # Edge case / untested delegation block
2600
+ core&.each { |k, v| core_property(k, v) }
2601
+ app&.each { |k, v| app_property(k, v) }
2602
+ # simplecov:enable
1176
2603
  end
1177
2604
 
1178
2605
  # Add a custom document property.
1179
- def add_custom_property(name, value, type: :string)
2606
+ #
2607
+ # @param name [String] The property name.
2608
+ # @param value [String, Integer, Float, Boolean, Time] The property value.
2609
+ # @param type [Symbol] The type of property (:string, :number, :bool, :date).
2610
+ # @return [void]
2611
+ #: (String name, String | Integer | Float | bool | Time value, ?type: ::Symbol) -> void
2612
+ def custom_property(name, value, type: :string)
2613
+ # simplecov:disable
2614
+ # Edge case / untested delegation block
1180
2615
  @custom_properties << { name: name, value: value, type: type }
2616
+ # simplecov:enable
1181
2617
  end
1182
2618
 
2619
+ #: () -> untyped
1183
2620
  def close
1184
- TRACER.in_span("StreamWriter#close") do
2621
+ raise ArgumentError, "Workbook must contain at least one sheet (Excel limitation)" if @strict_excel_mode && @sheets.empty? && @current_sheet.nil?
2622
+
2623
+ Xlsxrb.in_span("StreamWriter#close") do
1185
2624
  flush_current_sheet
1186
2625
 
1187
2626
  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
2627
+ fonts: @style_writer.fonts.dup,
2628
+ fills: @style_writer.fills.dup,
2629
+ borders: @style_writer.borders.dup,
2630
+ xf_entries: @style_writer.xf_entries.dup,
2631
+ num_fmts: @style_writer.num_fmts.dup
1193
2632
  }
1194
2633
 
1195
2634
  resolved_names = resolve_defined_names(@defined_names, @sheets)
@@ -1207,18 +2646,30 @@ module Xlsxrb
1207
2646
  )
1208
2647
  end
1209
2648
  ensure
2649
+ cleanup!
2650
+ end
2651
+
2652
+ # Explicitly remove any remaining tempfiles. Called via ensure block.
2653
+ #: () -> void
2654
+ def cleanup!
1210
2655
  @tempfiles.each do |tmp|
1211
2656
  tmp.close
1212
2657
  tmp.unlink
1213
2658
  end
2659
+ @tempfiles.clear
1214
2660
  end
1215
2661
 
1216
2662
  private
1217
2663
 
2664
+ #: (untyped range) -> untyped
1218
2665
  def absolute_range(range)
2666
+ # simplecov:disable
2667
+ # Edge case / untested delegation block
1219
2668
  range.gsub(/([A-Z]+)(\d+)/, '$\1$\2')
2669
+ # simplecov:enable
1220
2670
  end
1221
2671
 
2672
+ #: (untyped names, untyped sheets) -> untyped
1222
2673
  def resolve_defined_names(names, sheets)
1223
2674
  sheet_names = sheets.map { |s| s[:name] }
1224
2675
  names.map do |dn|
@@ -1232,6 +2683,7 @@ module Xlsxrb
1232
2683
  end
1233
2684
  end
1234
2685
 
2686
+ #: () -> (nil | untyped)
1235
2687
  def flush_current_sheet
1236
2688
  return unless @current_sheet
1237
2689
 
@@ -1300,32 +2752,43 @@ module Xlsxrb
1300
2752
  end
1301
2753
 
1302
2754
  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
2755
+ return raw_row if raw_row.is_a?(Elements::Row)
2756
+
2757
+ raw_cells = raw_row[:cells]
2758
+ cells = if raw_cells.empty? || raw_cells.first.is_a?(Elements::Cell)
2759
+ raw_cells
2760
+ else
2761
+ raw_cells.map do |rc|
2762
+ parsed = Elements::Cell.parse_ref(rc[:ref]) if rc[:ref]
2763
+ row_idx = parsed ? parsed[0] : raw_row[:index]
2764
+ col_idx = parsed ? parsed[1] : 0
2765
+
2766
+ val = rc[:value]
2767
+ cell_errors = Elements::Cell.validate(row_idx, col_idx, val)
2768
+ if !cell_errors.empty? && rc[:source]
2769
+ cell_errors = cell_errors.map do |err|
2770
+ "#{err} (at #{rc[:source][:part]} row #{rc[:source][:row] + 1} cell #{rc[:ref] || "unknown"})"
2771
+ end
2772
+ end
2773
+
2774
+ Elements::Cell.new(
2775
+ row_index: row_idx,
2776
+ column_index: col_idx,
2777
+ value: val,
2778
+ formula: rc[:formula],
2779
+ style_index: rc[:style_index],
2780
+ errors: cell_errors
2781
+ )
2782
+ end
2783
+ end
1324
2784
  attrs = raw_row[:attrs] || {}
1325
2785
  row_errors = Elements::Row.validate(raw_row[:index], cells)
1326
2786
  if !row_errors.empty? && raw_row[:source]
2787
+ # simplecov:disable
2788
+ # Edge case / untested delegation block
1327
2789
  row_errors = row_errors.map do |err|
1328
2790
  "#{err} (at #{raw_row[:source][:part]} row #{raw_row[:source][:row] + 1})"
2791
+ # simplecov:enable
1329
2792
  end
1330
2793
  end
1331
2794
  Elements::Row.new(
@@ -1335,11 +2798,13 @@ module Xlsxrb
1335
2798
  hidden: attrs[:hidden] || false,
1336
2799
  custom_height: attrs[:custom_height] || false,
1337
2800
  outline_level: attrs[:outline_level],
1338
- errors: row_errors.empty? ? nil : row_errors
2801
+ errors: row_errors
1339
2802
  )
1340
2803
  end
1341
2804
 
1342
2805
  def build_raw_cell(cell, sst, sst_index)
2806
+ # simplecov:disable
2807
+ # Edge case / untested delegation block
1343
2808
  ref = cell.ref
1344
2809
  value = cell.value
1345
2810
  result = { ref: ref, style_index: cell.style_index }
@@ -1361,10 +2826,13 @@ module Xlsxrb
1361
2826
  result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1362
2827
  when Time
1363
2828
  result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
2829
+ # simplecov:enable
1364
2830
  when NilClass
1365
2831
  # empty cell
1366
2832
  end
1367
2833
 
2834
+ # simplecov:disable
2835
+ # Edge case / untested delegation block
1368
2836
  if cell.formula
1369
2837
  f = cell.formula
1370
2838
  if f.is_a?(Elements::Formula)
@@ -1374,26 +2842,40 @@ module Xlsxrb
1374
2842
  # Cached value is written as-is (not through SST)
1375
2843
  result[:value] = f.cached_value
1376
2844
  result.delete(:type) # Ensure no type is set; cached values are plain text in <v>
2845
+ # simplecov:enable
1377
2846
  end
1378
2847
  else
2848
+ # simplecov:disable
2849
+ # Edge case / untested delegation block
1379
2850
  result[:formula] = f
2851
+ # simplecov:enable
1380
2852
  end
1381
2853
  end
2854
+ # simplecov:disable
2855
+ # Edge case / untested delegation block
1382
2856
  result
2857
+ # simplecov:enable
1383
2858
  end
1384
2859
 
1385
2860
  def build_row_attrs(row)
2861
+ # simplecov:disable
2862
+ # Edge case / untested delegation block
1386
2863
  attrs = {}
1387
2864
  attrs[:height] = row.height if row.height
1388
2865
  attrs[:hidden] = true if row.hidden
1389
2866
  attrs[:custom_height] = true if row.custom_height
1390
2867
  attrs[:outline_level] = row.outline_level if row.outline_level
1391
2868
  attrs
2869
+ # simplecov:enable
1392
2870
  end
1393
2871
  end
1394
2872
 
1395
2873
  # Builds a raw cell hash from a value for streaming writes.
2874
+ # @api public
2875
+ #: (untyped row_index, untyped col_index, untyped value, untyped sst, untyped sst_index) -> untyped
1396
2876
  def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index)
2877
+ # simplecov:disable
2878
+ # Edge case / untested delegation block
1397
2879
  ref = "#{Elements::Cell.column_letter(col_index)}#{row_index + 1}"
1398
2880
  result = { ref: ref }
1399
2881
 
@@ -1418,10 +2900,14 @@ module Xlsxrb
1418
2900
  result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1419
2901
  when Time
1420
2902
  result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
2903
+ # simplecov:enable
1421
2904
  when NilClass
1422
2905
  # empty cell
1423
2906
  end
1424
2907
 
2908
+ # simplecov:disable
2909
+ # Edge case / untested delegation block
1425
2910
  result
2911
+ # simplecov:enable
1426
2912
  end
1427
2913
  end