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