xlsxrb 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +71 -253
  4. data/Rakefile +2 -1
  5. data/docs/PEER_LIBRARIES.md +121 -0
  6. data/docs/assets/benchmark_results.svg +144 -0
  7. data/docs/assets/lsp_autocompletion.png +0 -0
  8. data/docs/assets/playground_preview.png +0 -0
  9. data/docs/visual/VisualGallery.md +183 -0
  10. data/docs/wasm/ruby.wasm +0 -0
  11. data/lib/xlsxrb/chart_builder.rb +333 -0
  12. data/lib/xlsxrb/elements/workbook.rb +2 -2
  13. data/lib/xlsxrb/ooxml/reader/listeners.rb +6450 -0
  14. data/lib/xlsxrb/ooxml/reader.rb +2 -6438
  15. data/lib/xlsxrb/ooxml/workbook_writer.rb +10 -10
  16. data/lib/xlsxrb/ooxml/worksheet_parser.rb +161 -116
  17. data/lib/xlsxrb/ooxml/worksheet_writer.rb +5 -1
  18. data/lib/xlsxrb/stream_row.rb +5 -3
  19. data/lib/xlsxrb/stream_sheet.rb +125 -0
  20. data/lib/xlsxrb/stream_writer.rb +1646 -0
  21. data/lib/xlsxrb/version.rb +1 -1
  22. data/lib/xlsxrb/workbook_builder.rb +309 -0
  23. data/lib/xlsxrb/worksheet_builder.rb +691 -0
  24. data/lib/xlsxrb.rb +238 -2968
  25. data/sig/generated/xlsxrb/chart_builder.rbs +269 -0
  26. data/sig/generated/xlsxrb/ooxml/reader/listeners.rbs +1074 -0
  27. data/sig/generated/xlsxrb/ooxml/reader.rbs +0 -1067
  28. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +3 -3
  29. data/sig/generated/xlsxrb/stream_row.rbs +3 -2
  30. data/sig/generated/xlsxrb/stream_sheet.rbs +100 -0
  31. data/sig/generated/xlsxrb/stream_writer.rbs +954 -0
  32. data/sig/generated/xlsxrb/workbook_builder.rbs +146 -0
  33. data/sig/generated/xlsxrb/worksheet_builder.rbs +349 -0
  34. data/sig/generated/xlsxrb.rbs +103 -1563
  35. metadata +17 -1
@@ -3,5 +3,5 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  module Xlsxrb
6
- VERSION = "0.1.9"
6
+ VERSION = "0.1.10"
7
7
  end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "worksheet_builder"
6
+
7
+ module Xlsxrb
8
+ # DSL context for building an in-memory {Elements::Workbook} in {Xlsxrb.build}.
9
+ #
10
+ # @example Build an in-memory workbook with multiple sheets
11
+ # workbook = Xlsxrb.build do |builder|
12
+ # builder.sheet("Sales") do |sheet|
13
+ # sheet.row(["Product", "Revenue"])
14
+ # sheet.row(["Widget", 1000])
15
+ # end
16
+ # builder.core_property(:creator, "Reporting System")
17
+ # end
18
+ #
19
+ # @api public
20
+ class WorkbookBuilder
21
+ # @param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specifications.
22
+ #: (?strict_excel_mode: bool) -> void
23
+ def initialize(strict_excel_mode: true)
24
+ @strict_excel_mode = strict_excel_mode
25
+ @sheets = []
26
+ @sheet_builders = [] # Keep track of sheet builders for style processing
27
+ @defined_names = []
28
+ @core_properties = {}
29
+ @app_properties = {}
30
+ @custom_properties = []
31
+ @workbook_protection = nil
32
+ @workbook_properties = { update_links: "never" }
33
+ end
34
+
35
+ # Sets a workbook-level property.
36
+ #
37
+ # @note **SECURITY WARNING:** If you set `:update_links` to anything other than `"never"`,
38
+ # you may expose end-users to malicious external reference vulnerabilities (e.g., CSV/DDE Injection)
39
+ # when they open the generated Excel file. Ensure you fully trust the exported data.
40
+ #
41
+ # @param name [Symbol] Property name (e.g. :update_links).
42
+ # @param value [String, Integer, Boolean] Property value.
43
+ # @return [void]
44
+ # @api public
45
+ #: (Symbol name, String | Integer | bool value) -> (String | Integer | bool)
46
+ def workbook_property(name, value)
47
+ @workbook_properties[name] = value
48
+ end
49
+
50
+ # Adds a new worksheet to the workbook.
51
+ #
52
+ # @param name [String, nil] Sheet name (max 31 chars, no forbidden chars `[ ] * ? / \`).
53
+ # @param opts [Hash] Sheet properties.
54
+ # @yield [sheet_builder]
55
+ # @yieldparam sheet_builder [Xlsxrb::WorksheetBuilder]
56
+ # @return [Elements::Worksheet]
57
+ # @raise [ArgumentError] If sheet name violates Excel limits in strict mode.
58
+ # @api public
59
+ #: (?String? name, **untyped opts) ?{ (WorksheetBuilder) -> void } -> untyped
60
+ def sheet(name = nil, **opts)
61
+ name ||= "Sheet#{@sheets.size + 1}"
62
+ raise ArgumentError, "Sheet name '#{name}' must be <= 31 characters (Excel limitation)" if @strict_excel_mode && name.length > 31
63
+ raise ArgumentError, "Sheet name '#{name}' contains invalid characters (ECMA-376 OOXML specification)" if name.match?(%r{[\[\]*?/\\]})
64
+ 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)
65
+
66
+ sheet_builder = WorksheetBuilder.new(name, strict_excel_mode: @strict_excel_mode)
67
+ opts.each { |k, v| sheet_builder.sheet_properties(k, v) }
68
+ yield sheet_builder if block_given?
69
+ @sheet_builders << sheet_builder
70
+ @sheets << sheet_builder.build
71
+ end
72
+ alias [] sheet
73
+
74
+ # --- Workbook-Level Methods ---
75
+
76
+ # Adds a defined name (named range or formula constant) to the workbook.
77
+ #
78
+ # @param name [String] Defined name (e.g. "TaxRate").
79
+ # @param value [String] Formula expression or range reference (e.g. "Sheet1!$B$2").
80
+ # @param sheet [String, nil] Optional sheet name for sheet-scoped defined names.
81
+ # @param hidden [Boolean] Whether the defined name is hidden from Excel's UI.
82
+ # @return [void]
83
+ # @api public
84
+ #: (String name, String value, ?sheet: String?, ?hidden: bool) -> void
85
+ def defined_name(name, value, sheet: nil, hidden: false)
86
+ entry = { name: name, value: value, hidden: hidden }
87
+ entry[:local_sheet_name] = sheet if sheet
88
+ @defined_names << entry
89
+ end
90
+
91
+ # Sets the print area for a sheet.
92
+ #
93
+ # @param range [String] Cell range (e.g. "A1:G50").
94
+ # @param sheet [String, nil] Target sheet name (defaults to latest or "Sheet1").
95
+ # @return [void]
96
+ # @api public
97
+ #: (String range, ?sheet: String?) -> void
98
+ def print_area(range, sheet: nil)
99
+ sheet_name = sheet || @sheets.last&.name || "Sheet1"
100
+ value = "'#{sheet_name}'!#{absolute_range(range)}"
101
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Area" && dn[:local_sheet_name] == sheet_name }
102
+ defined_name("_xlnm.Print_Area", value, sheet: sheet_name)
103
+ end
104
+
105
+ # Sets repeating print titles (rows and/or columns) for pagination.
106
+ #
107
+ # @param rows [String, nil] Repeating row range (e.g. "1:2").
108
+ # @param cols [String, nil] Repeating column range (e.g. "A:B").
109
+ # @param sheet [String, nil] Target sheet name.
110
+ # @return [void]
111
+ # @api public
112
+ #: (?rows: String?, ?cols: String?, ?sheet: String?) -> void
113
+ def print_titles(rows: nil, cols: nil, sheet: nil)
114
+ sheet_name = sheet || @sheets.last&.name || "Sheet1"
115
+ parts = []
116
+ parts << "'#{sheet_name}'!$#{cols.sub(":", ":$")}" if cols
117
+ parts << "'#{sheet_name}'!$#{rows.sub(":", ":$")}" if rows
118
+ value = parts.join(",")
119
+ @defined_names.reject! { |dn| dn[:name] == "_xlnm.Print_Titles" && dn[:local_sheet_name] == sheet_name }
120
+ defined_name("_xlnm.Print_Titles", value, sheet: sheet_name)
121
+ end
122
+
123
+ # Sets workbook structure and window protection.
124
+ #
125
+ # @param opts [Hash] Protection options (e.g. lock_structure: true, password: "secret").
126
+ # @return [void]
127
+ # @api public
128
+ #: (**String | Integer | bool | nil opts) -> void
129
+ def protect_workbook(**opts)
130
+ @workbook_protection = opts
131
+ end
132
+
133
+ # Sets a Dublin Core document metadata property.
134
+ #
135
+ # @param name [Symbol] Core property name (:creator, :title, :subject, :description, etc.).
136
+ # @param value [String, Integer, Time] Property value.
137
+ # @return [void]
138
+ # @api public
139
+ #: (Symbol name, String | Integer | Time value) -> void
140
+ def core_property(name, value)
141
+ @core_properties[name] = value
142
+ end
143
+
144
+ # Sets an extended application metadata property.
145
+ #
146
+ # @param name [Symbol] Application property name (:company, :manager, :app_version, etc.).
147
+ # @param value [String, Integer, Time] Property value.
148
+ # @return [void]
149
+ # @api public
150
+ #: (Symbol name, String | Integer | Time value) -> void
151
+ def app_property(name, value)
152
+ @app_properties[name] = value
153
+ end
154
+
155
+ # Sets multiple core, app, and/or custom properties simultaneously.
156
+ #
157
+ # @param core [Hash{Symbol => Object}, nil] Core properties map.
158
+ # @param app [Hash{Symbol => Object}, nil] App properties map.
159
+ # @param custom [Hash{String, Symbol => Object}, nil] Custom properties map.
160
+ # @return [void]
161
+ # @api public
162
+ #: (?core: Hash[Symbol, String | Integer | Time]?, ?app: Hash[Symbol, String | Integer | Time]?, ?custom: Hash[String | Symbol, untyped]?) -> void
163
+ def properties(core: nil, app: nil, custom: nil)
164
+ core&.each { |k, v| core_property(k, v) }
165
+ app&.each { |k, v| app_property(k, v) }
166
+ custom&.each { |k, v| custom_property(k.to_s, v) }
167
+ end
168
+
169
+ # Adds a custom document property.
170
+ #
171
+ # @param name [String] Property name.
172
+ # @param value [String, Integer, Float, Boolean, Time] Property value.
173
+ # @param type [Symbol] Value type (:string, :number, :bool, :date).
174
+ # @return [void]
175
+ # @api public
176
+ #: (String name, String | Integer | Float | bool | Time value, ?type: ::Symbol) -> void
177
+ def custom_property(name, value, type: :string)
178
+ @custom_properties << { name: name, value: value, type: type }
179
+ end
180
+
181
+ # Builds and returns the compiled {Elements::Workbook}.
182
+ #
183
+ # @return [Elements::Workbook]
184
+ # @raise [ArgumentError] If workbook contains zero sheets in strict mode.
185
+ # @api public
186
+ #: () -> Elements::Workbook
187
+ def build
188
+ raise ArgumentError, "Workbook must contain at least one sheet (Excel limitation)" if @strict_excel_mode && @sheets.empty?
189
+
190
+ # Process styles from all sheets and collect style definitions
191
+ processed_sheets, styles_definition = process_styles(@sheets)
192
+
193
+ # Store workbook-level metadata in unmapped_data
194
+ wb_meta = {}
195
+ wb_meta[:defined_names] = resolve_defined_names(@defined_names, processed_sheets) unless @defined_names.empty?
196
+ wb_meta[:core_properties] = @core_properties unless @core_properties.empty?
197
+ wb_meta[:app_properties] = @app_properties unless @app_properties.empty?
198
+ wb_meta[:custom_properties] = @custom_properties unless @custom_properties.empty?
199
+ wb_meta[:workbook_protection] = @workbook_protection if @workbook_protection
200
+ wb_meta[:workbook_properties] = @workbook_properties unless @workbook_properties.empty?
201
+
202
+ Elements::Workbook.new(
203
+ sheets: processed_sheets,
204
+ styles: styles_definition,
205
+ unmapped_data: wb_meta.empty? ? {} : { facade: wb_meta }
206
+ )
207
+ end
208
+
209
+ private
210
+
211
+ #: (untyped range) -> untyped
212
+ def absolute_range(range)
213
+ range.gsub(/([A-Z]+)(\d+)/, '$\1$\2')
214
+ end
215
+
216
+ #: (untyped names, untyped sheets) -> untyped
217
+ def resolve_defined_names(names, sheets)
218
+ sheet_names = sheets.map(&:name)
219
+ names.map do |dn|
220
+ resolved = dn.dup
221
+ if dn[:local_sheet_name]
222
+ idx = sheet_names.index(dn[:local_sheet_name])
223
+ resolved[:local_sheet_id] = idx if idx
224
+ resolved.delete(:local_sheet_name)
225
+ end
226
+ resolved
227
+ end
228
+ end
229
+
230
+ #: (untyped sheets) -> (::Array[untyped | ::Hash[untyped, untyped]] | ::Array[untyped])
231
+ def process_styles(sheets)
232
+ # Collect all unique StyleBuilders from all sheets
233
+ all_style_builders = {}
234
+ @sheet_builders.each do |sheet_builder|
235
+ sheet_builder.styles.each do |style_name, style_builder|
236
+ all_style_builders[style_name] = style_builder
237
+ end
238
+ end
239
+
240
+ return [sheets, {}] if all_style_builders.empty?
241
+
242
+ # Create a temporary writer to register styles and get numeric IDs
243
+ temp_writer = Ooxml::Writer.new
244
+ style_name_to_id = {}
245
+ all_style_builders.each do |name, builder|
246
+ style_id = builder.register_with(temp_writer)
247
+ style_name_to_id[name] = style_id
248
+ end
249
+
250
+ # Capture the style definitions from the temporary writer
251
+ styles_definition = extract_styles_from_writer(temp_writer)
252
+
253
+ # Update all cells with their resolved style IDs
254
+ updated_sheets = sheets.map do |sheet|
255
+ new_rows = sheet.rows.map do |row|
256
+ new_cells = row.cells.map do |cell|
257
+ # If style_index is a string (style name), resolve it to a numeric ID
258
+ if cell.style_index.is_a?(String) && style_name_to_id.key?(cell.style_index)
259
+ Elements::Cell.new(
260
+ row_index: cell.row_index,
261
+ column_index: cell.column_index,
262
+ value: cell.value,
263
+ formula: cell.formula,
264
+ style_index: style_name_to_id[cell.style_index],
265
+ unmapped_data: cell.unmapped_data,
266
+ errors: cell.errors
267
+ )
268
+ else
269
+ cell
270
+ end
271
+ end
272
+ Elements::Row.new(
273
+ index: row.index,
274
+ cells: new_cells,
275
+ height: row.height,
276
+ hidden: row.hidden,
277
+ custom_height: row.custom_height,
278
+ outline_level: row.outline_level,
279
+ unmapped_data: row.unmapped_data,
280
+ errors: row.errors
281
+ )
282
+ end
283
+ Elements::Worksheet.new(
284
+ name: sheet.name,
285
+ rows: new_rows,
286
+ columns: sheet.columns,
287
+ charts: sheet.charts,
288
+ unmapped_data: sheet.unmapped_data,
289
+ errors: sheet.errors
290
+ )
291
+ end
292
+
293
+ [updated_sheets, styles_definition]
294
+ end
295
+
296
+ #: (untyped writer) -> { fonts: untyped, fills: untyped, borders: untyped, xf_entries: untyped, num_fmts: untyped }
297
+ def extract_styles_from_writer(writer)
298
+ # Extract style definitions from the writer that can be reused
299
+ # This captures the fonts, fills, borders, and xf entries that were created
300
+ {
301
+ fonts: writer.fonts.dup,
302
+ fills: writer.fills.dup,
303
+ borders: writer.borders.dup,
304
+ xf_entries: writer.xf_entries.dup,
305
+ num_fmts: writer.num_fmts.dup
306
+ }
307
+ end
308
+ end
309
+ end