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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +71 -253
- data/Rakefile +2 -1
- data/docs/PEER_LIBRARIES.md +121 -0
- data/docs/assets/benchmark_results.svg +144 -0
- data/docs/assets/lsp_autocompletion.png +0 -0
- data/docs/assets/playground_preview.png +0 -0
- data/docs/visual/VisualGallery.md +183 -0
- data/docs/wasm/ruby.wasm +0 -0
- data/lib/xlsxrb/chart_builder.rb +333 -0
- data/lib/xlsxrb/elements/workbook.rb +2 -2
- data/lib/xlsxrb/ooxml/reader/listeners.rb +6450 -0
- data/lib/xlsxrb/ooxml/reader.rb +2 -6438
- data/lib/xlsxrb/ooxml/workbook_writer.rb +10 -10
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +161 -116
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +5 -1
- data/lib/xlsxrb/stream_row.rb +5 -3
- data/lib/xlsxrb/stream_sheet.rb +125 -0
- data/lib/xlsxrb/stream_writer.rb +1646 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +309 -0
- data/lib/xlsxrb/worksheet_builder.rb +691 -0
- data/lib/xlsxrb.rb +238 -2968
- data/sig/generated/xlsxrb/chart_builder.rbs +269 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners.rbs +1074 -0
- data/sig/generated/xlsxrb/ooxml/reader.rbs +0 -1067
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +3 -3
- data/sig/generated/xlsxrb/stream_row.rbs +3 -2
- data/sig/generated/xlsxrb/stream_sheet.rbs +100 -0
- data/sig/generated/xlsxrb/stream_writer.rbs +954 -0
- data/sig/generated/xlsxrb/workbook_builder.rbs +146 -0
- data/sig/generated/xlsxrb/worksheet_builder.rbs +349 -0
- data/sig/generated/xlsxrb.rbs +103 -1563
- metadata +17 -1
data/lib/xlsxrb/version.rb
CHANGED
|
@@ -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
|