xlsxrb 0.1.13 → 0.1.14
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 +57 -0
- data/README.md +2 -0
- data/lib/xlsxrb/colors.rb +118 -0
- data/lib/xlsxrb/elements/cell.rb +205 -16
- data/lib/xlsxrb/elements/column.rb +6 -4
- data/lib/xlsxrb/elements/coordinate_access.rb +32 -0
- data/lib/xlsxrb/elements/image.rb +128 -0
- data/lib/xlsxrb/elements/row.rb +206 -9
- data/lib/xlsxrb/elements/styles.rb +157 -0
- data/lib/xlsxrb/elements/types.rb +95 -1
- data/lib/xlsxrb/elements/workbook.rb +85 -4
- data/lib/xlsxrb/elements/worksheet.rb +424 -29
- data/lib/xlsxrb/elements.rb +2 -0
- data/lib/xlsxrb/number_formatter.rb +429 -0
- data/lib/xlsxrb/ooxml/cfb.rb +1 -1
- data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +7 -5
- data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +11 -4
- data/lib/xlsxrb/ooxml/reader.rb +12 -14
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +7 -1
- data/lib/xlsxrb/ooxml/styles_parser.rb +39 -9
- data/lib/xlsxrb/ooxml/utils.rb +34 -18
- data/lib/xlsxrb/ooxml/workbook_parser.rb +53 -15
- data/lib/xlsxrb/ooxml/workbook_writer.rb +65 -14
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +319 -79
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +206 -61
- data/lib/xlsxrb/ooxml/writer/features_xml.rb +4 -2
- data/lib/xlsxrb/ooxml/writer.rb +14 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +109 -25
- data/lib/xlsxrb/stream_row.rb +243 -16
- data/lib/xlsxrb/stream_sheet.rb +414 -15
- data/lib/xlsxrb/stream_writer.rb +213 -17
- data/lib/xlsxrb/style_builder.rb +308 -22
- data/lib/xlsxrb/utils.rb +158 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +73 -3
- data/lib/xlsxrb/worksheet_builder.rb +227 -16
- data/lib/xlsxrb.rb +578 -79
- data/sig/generated/xlsxrb/colors.rbs +40 -0
- data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
- data/sig/generated/xlsxrb/elements/column.rbs +3 -2
- data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
- data/sig/generated/xlsxrb/elements/image.rbs +82 -0
- data/sig/generated/xlsxrb/elements/row.rbs +83 -4
- data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
- data/sig/generated/xlsxrb/elements/types.rbs +43 -0
- data/sig/generated/xlsxrb/elements/workbook.rbs +51 -2
- data/sig/generated/xlsxrb/elements/worksheet.rbs +231 -18
- data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
- data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
- data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
- data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
- data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +28 -6
- data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +3 -1
- data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +10 -0
- data/sig/generated/xlsxrb/stream_row.rbs +100 -6
- data/sig/generated/xlsxrb/stream_sheet.rbs +247 -13
- data/sig/generated/xlsxrb/stream_writer.rbs +120 -14
- data/sig/generated/xlsxrb/style_builder.rbs +55 -18
- data/sig/generated/xlsxrb/utils.rbs +119 -0
- data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
- data/sig/generated/xlsxrb/worksheet_builder.rbs +107 -11
- data/sig/generated/xlsxrb.rbs +186 -27
- metadata +11 -1
data/lib/xlsxrb.rb
CHANGED
|
@@ -27,12 +27,15 @@ require_relative "xlsxrb/ooxml"
|
|
|
27
27
|
require_relative "xlsxrb/elements"
|
|
28
28
|
require_relative "xlsxrb/stream_row"
|
|
29
29
|
require_relative "xlsxrb/stream_sheet"
|
|
30
|
+
require_relative "xlsxrb/colors"
|
|
30
31
|
require_relative "xlsxrb/style_builder"
|
|
31
32
|
require_relative "xlsxrb/chart_builder"
|
|
32
33
|
require_relative "xlsxrb/dsl_helpers"
|
|
33
34
|
require_relative "xlsxrb/worksheet_builder"
|
|
34
35
|
require_relative "xlsxrb/workbook_builder"
|
|
36
|
+
require_relative "xlsxrb/number_formatter"
|
|
35
37
|
require_relative "xlsxrb/stream_writer"
|
|
38
|
+
require_relative "xlsxrb/utils"
|
|
36
39
|
|
|
37
40
|
# Modern, streaming-capable, low-memory XLSX reading and writing library for Ruby.
|
|
38
41
|
#
|
|
@@ -56,6 +59,9 @@ module Xlsxrb
|
|
|
56
59
|
# Raised when parsing malformed or invalid XML structures.
|
|
57
60
|
class ParseError < Error; end
|
|
58
61
|
|
|
62
|
+
OMITTED_TARGET = Object.new.freeze
|
|
63
|
+
private_constant :OMITTED_TARGET
|
|
64
|
+
|
|
59
65
|
# Raised when cell, row, or workbook parameters fail specification validation.
|
|
60
66
|
class ValidationError < Error; end
|
|
61
67
|
|
|
@@ -73,23 +79,61 @@ module Xlsxrb
|
|
|
73
79
|
|
|
74
80
|
# Helper to construct {Elements::RichText} objects with formatted text runs.
|
|
75
81
|
#
|
|
76
|
-
# @example Create multi-run rich text
|
|
82
|
+
# @example Create multi-run rich text with hashes
|
|
77
83
|
# rt = Xlsxrb.rich_text({ text: "Total: ", font: { bold: true } }, { text: "$100" })
|
|
78
84
|
#
|
|
85
|
+
# @example Create rich text with text/font pairs
|
|
86
|
+
# rt = Xlsxrb.rich_text("Normal text, ", ["bold text, ", { bold: true }], ["red text", { color: "FF0000" }])
|
|
87
|
+
#
|
|
88
|
+
# @example Create rich text with format objects implementing to_font_hash
|
|
89
|
+
# rt = Xlsxrb.rich_text("Normal text, ", bold_format, "bold text")
|
|
90
|
+
#
|
|
79
91
|
# @example Create simple styled text
|
|
80
92
|
# rt = Xlsxrb.rich_text(text: "Important Notice", bold: true, color: "FF0000")
|
|
81
93
|
#
|
|
82
|
-
# @param runs [Array<Hash, String>] Optional array of rich text run
|
|
94
|
+
# @param runs [Array<Hash, String, Array, Elements::RichTextRun, Object>] Optional array of rich text run items.
|
|
83
95
|
# @param text [String, nil] Plain text string (convenience parameter).
|
|
84
96
|
# @param font_props [Hash] Inline font styling options (e.g. bold: true, color: "FF0000").
|
|
85
97
|
# @return [Elements::RichText] The compiled rich text element.
|
|
86
98
|
# @api public
|
|
87
|
-
#: (*(Hash[Symbol, untyped] | String) runs, ?text: String?, **untyped font_props) -> Elements::RichText
|
|
99
|
+
#: (*(Hash[Symbol, untyped] | Elements::RichTextRun | String | Array[untyped] | untyped) runs, ?text: String?, **untyped font_props) -> Elements::RichText
|
|
88
100
|
def self.rich_text(*runs, text: nil, **font_props)
|
|
89
101
|
if text
|
|
90
|
-
runs = [
|
|
102
|
+
runs = [Elements::RichTextRun.new(text: text, font: font_props.empty? ? nil : font_props)]
|
|
91
103
|
elsif runs.size == 1 && runs.first.is_a?(String) && !font_props.empty?
|
|
92
|
-
runs = [
|
|
104
|
+
runs = [Elements::RichTextRun.new(text: runs.first, font: font_props)]
|
|
105
|
+
else
|
|
106
|
+
normalized_runs = []
|
|
107
|
+
pending_font = nil
|
|
108
|
+
|
|
109
|
+
runs.each do |item|
|
|
110
|
+
if item.respond_to?(:to_font_hash)
|
|
111
|
+
pending_font = item.to_font_hash
|
|
112
|
+
elsif item.is_a?(Hash) && !item.key?(:text)
|
|
113
|
+
pending_font = item
|
|
114
|
+
elsif item.is_a?(Elements::RichTextRun)
|
|
115
|
+
normalized_runs << item
|
|
116
|
+
pending_font = nil
|
|
117
|
+
elsif item.is_a?(Hash)
|
|
118
|
+
f = item[:font] || pending_font
|
|
119
|
+
f = f.to_font_hash if f.respond_to?(:to_font_hash)
|
|
120
|
+
normalized_runs << Elements::RichTextRun.new(text: item[:text].to_s, font: f)
|
|
121
|
+
pending_font = nil
|
|
122
|
+
elsif item.is_a?(Array)
|
|
123
|
+
t = item[0].to_s
|
|
124
|
+
f = item[1] || pending_font
|
|
125
|
+
f = f.to_font_hash if f.respond_to?(:to_font_hash)
|
|
126
|
+
f = f.to_h if f.respond_to?(:to_h) && !f.is_a?(Hash)
|
|
127
|
+
normalized_runs << Elements::RichTextRun.new(text: t, font: f.is_a?(Hash) ? f : nil)
|
|
128
|
+
pending_font = nil
|
|
129
|
+
elsif pending_font
|
|
130
|
+
normalized_runs << Elements::RichTextRun.new(text: item.to_s, font: pending_font)
|
|
131
|
+
pending_font = nil
|
|
132
|
+
else
|
|
133
|
+
normalized_runs << Elements::RichTextRun.new(text: item.to_s)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
runs = normalized_runs
|
|
93
137
|
end
|
|
94
138
|
Elements::RichText.new(runs: runs)
|
|
95
139
|
end
|
|
@@ -104,35 +148,196 @@ module Xlsxrb
|
|
|
104
148
|
#
|
|
105
149
|
# @param expression [String] The formula expression without leading '=' (e.g. "SUM(A1:A10)").
|
|
106
150
|
# @param cached_value [Object, nil] Optional precomputed value for readers that do not evaluate formulas.
|
|
151
|
+
# @param type [Symbol, String, nil] Formula type (:shared, :array, etc.).
|
|
152
|
+
# @param ref [String, nil] Target cell or range reference (e.g. "A1:C1").
|
|
153
|
+
# @return [Elements::Formula]
|
|
154
|
+
# @api public
|
|
155
|
+
#: (String expression, ?cached_value: (String | Numeric | bool | nil), ?type: (Symbol | String)?, ?ref: String?) -> Elements::Formula
|
|
156
|
+
def self.formula(expression, cached_value: nil, type: nil, ref: nil)
|
|
157
|
+
Elements::Formula.new(
|
|
158
|
+
expression: expression,
|
|
159
|
+
cached_value: cached_value,
|
|
160
|
+
type: type,
|
|
161
|
+
ref: ref,
|
|
162
|
+
calculate_always: cached_value.nil? || nil
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Creates an array or dynamic array {Elements::Formula} object.
|
|
167
|
+
#
|
|
168
|
+
# @example Create a multi-cell array formula
|
|
169
|
+
# formula = Xlsxrb.array_formula("SUM(B1:B10*C1:C10)", "A1:C1", cached_value: 0)
|
|
170
|
+
#
|
|
171
|
+
# @example Create a dynamic array formula
|
|
172
|
+
# formula = Xlsxrb.array_formula("SORT(UNIQUE(A1:A10))", "D1")
|
|
173
|
+
#
|
|
174
|
+
# @param expression [String] The formula expression without leading '='.
|
|
175
|
+
# @param ref [String, nil] Target cell or range reference (e.g. "A1:C1" or "D1").
|
|
176
|
+
# @param cached_value [Object, nil] Optional precomputed value.
|
|
107
177
|
# @return [Elements::Formula]
|
|
108
178
|
# @api public
|
|
109
|
-
#: (String expression, ?cached_value: (String | Numeric | bool | nil)) -> Elements::Formula
|
|
110
|
-
def self.
|
|
179
|
+
#: (String expression, ?String? ref, ?cached_value: (String | Numeric | bool | nil)) -> Elements::Formula
|
|
180
|
+
def self.array_formula(expression, ref = nil, cached_value: nil)
|
|
111
181
|
Elements::Formula.new(
|
|
112
182
|
expression: expression,
|
|
113
183
|
cached_value: cached_value,
|
|
184
|
+
type: :array,
|
|
185
|
+
ref: ref,
|
|
114
186
|
calculate_always: cached_value.nil? || nil
|
|
115
187
|
)
|
|
116
188
|
end
|
|
117
189
|
|
|
190
|
+
# Creates an {Elements::Hyperlink} object for use in cell hyperlinks.
|
|
191
|
+
#
|
|
192
|
+
# @example Create an external hyperlink with a ScreenTip tooltip
|
|
193
|
+
# link = Xlsxrb.hyperlink("https://example.com", tooltip: "Visit Example")
|
|
194
|
+
#
|
|
195
|
+
# @example Create an internal worksheet link
|
|
196
|
+
# link = Xlsxrb.hyperlink(location: "Sheet2!A1", tooltip: "Go to Sheet 2")
|
|
197
|
+
#
|
|
198
|
+
# @param url [String, nil] External hyperlink URL.
|
|
199
|
+
# @param display [String, nil] Optional display label.
|
|
200
|
+
# @param tooltip [String, nil] Optional hover ScreenTip text.
|
|
201
|
+
# @param location [String, nil] Optional internal sheet coordinate.
|
|
202
|
+
# @return [Elements::Hyperlink]
|
|
203
|
+
# @api public
|
|
204
|
+
#: (?String? url, ?display: String?, ?tooltip: String?, ?location: String?) -> Elements::Hyperlink
|
|
205
|
+
def self.hyperlink(url = nil, display: nil, tooltip: nil, location: nil)
|
|
206
|
+
Elements::Hyperlink.new(url: url, display: display, tooltip: tooltip, location: location)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Formats a cell value according to an Excel format code string.
|
|
210
|
+
#
|
|
211
|
+
# @param value [Object, nil] Value to format.
|
|
212
|
+
# @param format_code [String, nil] Excel format code string.
|
|
213
|
+
# @param date1904 [Boolean] Whether the 1904 date system is active.
|
|
214
|
+
# @return [String] Formatted string representation.
|
|
215
|
+
# @api public
|
|
216
|
+
#: (untyped value, ?String? format_code, ?date1904: bool) -> String
|
|
217
|
+
def self.format(value, format_code = nil, date1904: false)
|
|
218
|
+
NumberFormatter.format(value, format_code, date1904: date1904)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Converts a Date or Time to an Excel numeric serial number (1900 or 1904 system).
|
|
222
|
+
#
|
|
223
|
+
# @example
|
|
224
|
+
# Xlsxrb.date_to_serial(Date.new(2026, 1, 1)) #=> 46023
|
|
225
|
+
#
|
|
226
|
+
# @param date_or_time [Date, Time] Date or Time instance.
|
|
227
|
+
# @param date1904 [Boolean] Whether to use the 1904 date system.
|
|
228
|
+
# @return [Integer, Float] Serial number.
|
|
229
|
+
# @api public
|
|
230
|
+
#: (Date | Time date_or_time, ?date1904: bool) -> (Integer | Float)
|
|
231
|
+
def self.date_to_serial(date_or_time, date1904: false)
|
|
232
|
+
Utils.date_to_serial(date_or_time, date1904: date1904)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Converts an Excel serial number to a Date.
|
|
236
|
+
#
|
|
237
|
+
# @example
|
|
238
|
+
# Xlsxrb.serial_to_date(46023) #=> #<Date: 2026-01-01>
|
|
239
|
+
#
|
|
240
|
+
# @param serial [Numeric] Excel serial number.
|
|
241
|
+
# @param date1904 [Boolean] Whether to use the 1904 date system.
|
|
242
|
+
# @return [Date]
|
|
243
|
+
# @api public
|
|
244
|
+
#: (Numeric serial, ?date1904: bool) -> Date
|
|
245
|
+
def self.serial_to_date(serial, date1904: false)
|
|
246
|
+
Utils.serial_to_date(serial, date1904: date1904)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Converts a Time or Date to a fractional Excel serial number.
|
|
250
|
+
#
|
|
251
|
+
# @example
|
|
252
|
+
# Xlsxrb.datetime_to_serial(Time.utc(2026, 1, 1, 12, 0, 0)) #=> 46023.5
|
|
253
|
+
#
|
|
254
|
+
# @param time [Time, Date] Time or Date instance.
|
|
255
|
+
# @param date1904 [Boolean] Whether to use the 1904 date system.
|
|
256
|
+
# @return [Float] Fractional serial number.
|
|
257
|
+
# @api public
|
|
258
|
+
#: (Time | Date time, ?date1904: bool) -> Float
|
|
259
|
+
def self.datetime_to_serial(time, date1904: false)
|
|
260
|
+
Utils.datetime_to_serial(time, date1904: date1904)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Converts an Excel serial number to a Time (UTC).
|
|
264
|
+
#
|
|
265
|
+
# @example
|
|
266
|
+
# Xlsxrb.serial_to_time(46023.5) #=> 2026-01-01 12:00:00 UTC
|
|
267
|
+
#
|
|
268
|
+
# @param serial [Numeric] Excel serial number.
|
|
269
|
+
# @param date1904 [Boolean] Whether to use the 1904 date system.
|
|
270
|
+
# @return [Time]
|
|
271
|
+
# @api public
|
|
272
|
+
#: (Numeric serial, ?date1904: bool) -> Time
|
|
273
|
+
def self.serial_to_time(serial, date1904: false)
|
|
274
|
+
Utils.serial_to_time(serial, date1904: date1904)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
class << self
|
|
278
|
+
alias serial_to_datetime serial_to_time
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Resolves an ECMA-376 indexed color palette index (0..63) to an RRGGBB hex string (or AARRGGBB if alpha: true).
|
|
282
|
+
#
|
|
283
|
+
# @example
|
|
284
|
+
# Xlsxrb.palette_color(12) #=> "0000FF"
|
|
285
|
+
# Xlsxrb.palette_color(12, alpha: true) #=> "FF0000FF"
|
|
286
|
+
#
|
|
287
|
+
# @param index [Object, nil] Palette index (0..63).
|
|
288
|
+
# @param alpha [Boolean] Whether to prefix "FF" alpha channel.
|
|
289
|
+
# @return [String, nil]
|
|
290
|
+
# @api public
|
|
291
|
+
#: (untyped index, ?alpha: bool) -> String?
|
|
292
|
+
def self.palette_color(index, alpha: false)
|
|
293
|
+
Colors.palette_color(index, alpha: alpha)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Converts any supported color representation (palette index 0..63, CSS name, #RGB, #RRGGBB, #AARRGGBB, integer RGB)
|
|
297
|
+
# to a normalized hex string.
|
|
298
|
+
#
|
|
299
|
+
# @example
|
|
300
|
+
# Xlsxrb.to_hex_color(12) #=> "0000FF"
|
|
301
|
+
# Xlsxrb.to_hex_color(:navy) #=> "000080"
|
|
302
|
+
# Xlsxrb.to_hex_color("#FF0000") #=> "FF0000"
|
|
303
|
+
#
|
|
304
|
+
# @param color [Integer, String, Symbol, nil]
|
|
305
|
+
# @param alpha [Boolean] Whether to include the alpha channel (default false -> "RRGGBB").
|
|
306
|
+
# @return [String, nil]
|
|
307
|
+
# @api public
|
|
308
|
+
#: (Integer | String | Symbol | nil color, ?alpha: bool) -> String?
|
|
309
|
+
def self.to_hex_color(color, alpha: false)
|
|
310
|
+
Colors.to_hex(color, alpha: alpha)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
class << self
|
|
314
|
+
alias color_to_hex to_hex_color
|
|
315
|
+
end
|
|
316
|
+
|
|
118
317
|
# Reads an XLSX file (streaming and lazy-loaded by default) from a file path, IO stream, or binary String.
|
|
119
318
|
#
|
|
120
319
|
# Sheets and rows are streamed lazily with O(1) constant memory. If a block is given,
|
|
121
320
|
# yields each {StreamSheet} sequentially. Call {#load} on the returned Workbook or Sheet
|
|
122
321
|
# to convert to an in-memory representation for coordinate random access (`sheet["A1"]`).
|
|
123
322
|
#
|
|
124
|
-
# @overload read(source, password: nil, &block)
|
|
323
|
+
# @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &block)
|
|
125
324
|
# Yields each {StreamSheet} sequentially in streaming mode.
|
|
126
325
|
# @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
|
|
127
326
|
# @param password [String, nil] Optional password to decrypt password-protected XLSX files.
|
|
327
|
+
# @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
|
|
328
|
+
# @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
|
|
329
|
+
# @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
|
|
128
330
|
# @yield [sheet]
|
|
129
331
|
# @yieldparam sheet [StreamSheet] The streaming worksheet object.
|
|
130
332
|
# @return [void]
|
|
131
333
|
#
|
|
132
|
-
# @overload read(source, password: nil)
|
|
334
|
+
# @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false)
|
|
133
335
|
# Returns a lazy {Elements::Workbook} object.
|
|
134
336
|
# @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
|
|
135
337
|
# @param password [String, nil] Optional password to decrypt password-protected XLSX files.
|
|
338
|
+
# @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
|
|
339
|
+
# @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
|
|
340
|
+
# @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
|
|
136
341
|
# @return [Elements::Workbook]
|
|
137
342
|
#
|
|
138
343
|
# @example Streaming read across sheets and rows (O(1) constant memory)
|
|
@@ -153,9 +358,9 @@ module Xlsxrb
|
|
|
153
358
|
# @raise [InvalidPasswordError] If the supplied password is incorrect.
|
|
154
359
|
# @raise [ParseError] If the spreadsheet XML structure is invalid.
|
|
155
360
|
# @api public
|
|
156
|
-
#: (untyped source, ?password: String?) { (StreamSheet) -> void } -> void
|
|
157
|
-
#: (untyped source, ?password: String?) -> Elements::Workbook
|
|
158
|
-
def self.read(source, password: nil, &)
|
|
361
|
+
#: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) { (StreamSheet) -> void } -> void
|
|
362
|
+
#: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) -> Elements::Workbook
|
|
363
|
+
def self.read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &)
|
|
159
364
|
prepared_source = prepare_source_io(source, password)
|
|
160
365
|
zip_reader = Ooxml::ZipReader.open(prepared_source)
|
|
161
366
|
begin
|
|
@@ -163,7 +368,9 @@ module Xlsxrb
|
|
|
163
368
|
shared_strings = Ooxml::SharedStringsParser.parse(shared_strings_xml)
|
|
164
369
|
|
|
165
370
|
workbook_xml = zip_reader.read_entry("xl/workbook.xml")
|
|
166
|
-
|
|
371
|
+
wb_parsed = Ooxml::WorkbookParser.parse_with_properties(workbook_xml)
|
|
372
|
+
workbook_sheets = wb_parsed[:sheets]
|
|
373
|
+
is_date1904 = wb_parsed[:date1904]
|
|
167
374
|
|
|
168
375
|
rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
|
|
169
376
|
rels = Ooxml::RelationshipsParser.parse(rels_xml)
|
|
@@ -186,11 +393,25 @@ module Xlsxrb
|
|
|
186
393
|
shared_strings,
|
|
187
394
|
styles,
|
|
188
395
|
zip_reader: zip_reader,
|
|
189
|
-
entry_name: sheet_path
|
|
396
|
+
entry_name: sheet_path,
|
|
397
|
+
state: sheet_info[:state] || :visible,
|
|
398
|
+
date1904: is_date1904,
|
|
399
|
+
trim_empty_rows: trim_empty_rows,
|
|
400
|
+
pad_empty_rows: pad_empty_rows,
|
|
401
|
+
pad_empty_cells: pad_empty_cells
|
|
190
402
|
)
|
|
191
403
|
end.compact
|
|
192
404
|
|
|
193
|
-
|
|
405
|
+
wb_unmapped = {}
|
|
406
|
+
wb_unmapped[:workbook_properties] = { date1904: true } if is_date1904
|
|
407
|
+
defined_names = wb_parsed[:defined_names] || []
|
|
408
|
+
wb = Elements::Workbook.new(
|
|
409
|
+
sheets: sheets,
|
|
410
|
+
shared_strings: shared_strings,
|
|
411
|
+
styles: styles,
|
|
412
|
+
unmapped_data: wb_unmapped,
|
|
413
|
+
defined_names: defined_names
|
|
414
|
+
)
|
|
194
415
|
|
|
195
416
|
if block_given?
|
|
196
417
|
sheets.each(&)
|
|
@@ -203,17 +424,22 @@ module Xlsxrb
|
|
|
203
424
|
end
|
|
204
425
|
end
|
|
205
426
|
|
|
427
|
+
class << self
|
|
428
|
+
alias stream read
|
|
429
|
+
end
|
|
430
|
+
|
|
206
431
|
# Writes an XLSX file or IO stream (streaming or in-memory), or returns a binary string.
|
|
207
432
|
#
|
|
208
|
-
# @overload write(target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
|
|
433
|
+
# @overload write(?target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
|
|
209
434
|
# Streaming write: yields a {StreamWriter} context for high-speed, zero-allocation XLSX generation.
|
|
210
|
-
#
|
|
435
|
+
# If target is omitted, returns the generated XLSX as an in-memory binary String.
|
|
436
|
+
# @param target [String, IO, StringIO, nil] Optional destination file path or writable IO object.
|
|
211
437
|
# @param password [String, nil] Optional password to encrypt the generated XLSX file.
|
|
212
438
|
# @param encryption_mode [Symbol] Encryption algorithm (:standard or :agile).
|
|
213
439
|
# @param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specification limits.
|
|
214
440
|
# @yield [stream_writer]
|
|
215
441
|
# @yieldparam stream_writer [Xlsxrb::StreamWriter]
|
|
216
|
-
# @return [void]
|
|
442
|
+
# @return [String, void] Binary data if target is omitted, or void if written to target.
|
|
217
443
|
#
|
|
218
444
|
# @overload write(workbook, password: nil, encryption_mode: :standard)
|
|
219
445
|
# In-memory write: exports the workbook to an in-memory binary String.
|
|
@@ -235,6 +461,11 @@ module Xlsxrb
|
|
|
235
461
|
# writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
|
|
236
462
|
# end
|
|
237
463
|
#
|
|
464
|
+
# @example Streaming write to in-memory binary string (target omitted)
|
|
465
|
+
# binary_data = Xlsxrb.write do |writer|
|
|
466
|
+
# writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
|
|
467
|
+
# end
|
|
468
|
+
#
|
|
238
469
|
# @example Password-protected streaming write
|
|
239
470
|
# Xlsxrb.write("protected.xlsx", password: "SecretPassword123") do |writer|
|
|
240
471
|
# writer.sheet("Confidential") { |s| s.row(["Private Data", 100]) }
|
|
@@ -246,9 +477,16 @@ module Xlsxrb
|
|
|
246
477
|
# @api public
|
|
247
478
|
#: (Elements::Workbook workbook, ?password: String?, ?encryption_mode: Symbol) -> String
|
|
248
479
|
#: (untyped target, Elements::Workbook | untyped workbook, ?password: String?, ?encryption_mode: Symbol) -> void
|
|
249
|
-
#: (untyped target_or_workbook, ?Elements::Workbook | untyped workbook_or_nil, ?password: String?, ?encryption_mode: Symbol, ?strict_excel_mode: bool) ?{ (StreamWriter) -> void } -> untyped
|
|
250
|
-
def self.write(target_or_workbook, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &
|
|
480
|
+
#: (?untyped target_or_workbook, ?Elements::Workbook | untyped workbook_or_nil, ?password: String?, ?encryption_mode: Symbol, ?strict_excel_mode: bool) ?{ (StreamWriter) -> void } -> untyped
|
|
481
|
+
def self.write(target_or_workbook = OMITTED_TARGET, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &)
|
|
251
482
|
if block_given?
|
|
483
|
+
if target_or_workbook.equal?(OMITTED_TARGET)
|
|
484
|
+
io = StringIO.new
|
|
485
|
+
io.binmode
|
|
486
|
+
write(io, password: password, encryption_mode: encryption_mode, strict_excel_mode: strict_excel_mode, &)
|
|
487
|
+
return io.string.b
|
|
488
|
+
end
|
|
489
|
+
|
|
252
490
|
target = target_or_workbook
|
|
253
491
|
raise Error, "target is required" if target.nil?
|
|
254
492
|
|
|
@@ -282,6 +520,8 @@ module Xlsxrb
|
|
|
282
520
|
end
|
|
283
521
|
|
|
284
522
|
if workbook_or_nil.nil?
|
|
523
|
+
raise Error, "workbook must be an Elements::Workbook" if target_or_workbook.equal?(OMITTED_TARGET)
|
|
524
|
+
|
|
285
525
|
wb = target_or_workbook
|
|
286
526
|
raise Error, "workbook must be an Elements::Workbook" unless wb.is_a?(Elements::Workbook)
|
|
287
527
|
|
|
@@ -299,6 +539,15 @@ module Xlsxrb
|
|
|
299
539
|
sst = []
|
|
300
540
|
sst_index = {}
|
|
301
541
|
|
|
542
|
+
if workbook.shared_strings && !workbook.shared_strings.empty?
|
|
543
|
+
workbook.shared_strings.each do |val|
|
|
544
|
+
if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
|
|
545
|
+
sst << val
|
|
546
|
+
sst_index[val] = sst.size - 1
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
302
551
|
# Collect shared strings and build index without allocating new Hashes
|
|
303
552
|
sheet_data = workbook.sheets.map do |raw_ws|
|
|
304
553
|
ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
|
|
@@ -314,21 +563,51 @@ module Xlsxrb
|
|
|
314
563
|
columns = ws.columns.map do |col|
|
|
315
564
|
# simplecov:disable
|
|
316
565
|
# Edge case / untested delegation block
|
|
317
|
-
|
|
566
|
+
style_idx = col.unmapped_data&.[](:style_index) || col.unmapped_data&.[](:style) || (col.respond_to?(:style_index) && col.style_index)
|
|
567
|
+
{ index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level, style_index: style_idx }
|
|
318
568
|
# simplecov:enable
|
|
319
569
|
end
|
|
320
570
|
sd = { name: ws.name, rows: ws.rows, columns: columns }
|
|
321
571
|
sd[:charts] = ws.charts unless ws.charts.empty?
|
|
572
|
+
sd[:conditional_formats] = ws.conditional_formatting unless ws.conditional_formatting.empty?
|
|
573
|
+
sd[:data_validations] = ws.data_validations unless ws.data_validations.empty?
|
|
322
574
|
|
|
323
575
|
# Extract facade metadata from unmapped_data
|
|
324
576
|
facade = ws.unmapped_data[:facade]
|
|
325
|
-
facade&.each { |key, val| sd[key]
|
|
577
|
+
facade&.each { |key, val| sd[key] ||= val }
|
|
578
|
+
|
|
579
|
+
sd[:state] ||= ws.state if ws.respond_to?(:state) && ws.state && ws.state != :visible
|
|
580
|
+
sd[:comments] ||= ws.comments if ws.respond_to?(:comments) && !ws.comments.empty?
|
|
581
|
+
if ws.respond_to?(:hyperlinks) && !ws.hyperlinks.empty?
|
|
582
|
+
sd[:hyperlinks] ||= ws.hyperlinks.is_a?(Hash) ? ws.hyperlinks.values : ws.hyperlinks
|
|
583
|
+
end
|
|
584
|
+
sd[:sparkline_groups] ||= ws.sparkline_groups if ws.respond_to?(:sparkline_groups) && !ws.sparkline_groups.empty?
|
|
326
585
|
|
|
327
586
|
sd
|
|
328
587
|
end
|
|
329
588
|
|
|
330
589
|
# Extract workbook-level facade metadata
|
|
331
590
|
wb_facade = workbook.unmapped_data[:facade] || {}
|
|
591
|
+
wb_properties = workbook.unmapped_data[:workbook_properties] || wb_facade[:workbook_properties]
|
|
592
|
+
raw_dns = wb_facade[:defined_names] || (workbook.respond_to?(:defined_names) && !workbook.defined_names.empty? ? workbook.defined_names : nil)
|
|
593
|
+
defined_names = raw_dns ? raw_dns.map(&:dup) : []
|
|
594
|
+
|
|
595
|
+
# Auto-generate _xlnm.Print_Area and _xlnm.Print_Titles from worksheet definitions if not already present
|
|
596
|
+
workbook.sheets.each_with_index do |ws, idx|
|
|
597
|
+
sheet_name = ws.name || "Sheet#{idx + 1}"
|
|
598
|
+
if ws.respond_to?(:print_area) && ws.print_area && defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Area" && (dn[:local_sheet_id] == idx || dn[:local_sheet_name] == sheet_name) }
|
|
599
|
+
val = "'#{sheet_name}'!#{DslHelpers.absolute_range(ws.print_area)}"
|
|
600
|
+
defined_names << { name: "_xlnm.Print_Area", value: val, local_sheet_id: idx }
|
|
601
|
+
end
|
|
602
|
+
next unless ws.respond_to?(:print_titles) && ws.print_titles && defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Titles" && (dn[:local_sheet_id] == idx || dn[:local_sheet_name] == sheet_name) }
|
|
603
|
+
|
|
604
|
+
pt = ws.print_titles
|
|
605
|
+
parts = []
|
|
606
|
+
parts << "'#{sheet_name}'!$#{pt[:cols].sub(":", ":$")}" if pt[:cols]
|
|
607
|
+
parts << "'#{sheet_name}'!$#{pt[:rows].sub(":", ":$")}" if pt[:rows]
|
|
608
|
+
defined_names << { name: "_xlnm.Print_Titles", value: parts.join(","), local_sheet_id: idx } unless parts.empty?
|
|
609
|
+
end
|
|
610
|
+
defined_names = nil if defined_names.empty?
|
|
332
611
|
|
|
333
612
|
if password && !password.empty?
|
|
334
613
|
buf = StringIO.new
|
|
@@ -339,12 +618,12 @@ module Xlsxrb
|
|
|
339
618
|
shared_strings: sst,
|
|
340
619
|
shared_strings_index: sst_index,
|
|
341
620
|
styles: workbook.styles,
|
|
342
|
-
defined_names:
|
|
621
|
+
defined_names: defined_names,
|
|
343
622
|
core_properties: wb_facade[:core_properties],
|
|
344
623
|
app_properties: wb_facade[:app_properties],
|
|
345
624
|
custom_properties: wb_facade[:custom_properties],
|
|
346
625
|
workbook_protection: wb_facade[:workbook_protection],
|
|
347
|
-
workbook_properties:
|
|
626
|
+
workbook_properties: wb_properties
|
|
348
627
|
)
|
|
349
628
|
encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
|
|
350
629
|
if target.is_a?(String)
|
|
@@ -359,12 +638,12 @@ module Xlsxrb
|
|
|
359
638
|
shared_strings: sst,
|
|
360
639
|
shared_strings_index: sst_index,
|
|
361
640
|
styles: workbook.styles,
|
|
362
|
-
defined_names:
|
|
641
|
+
defined_names: defined_names,
|
|
363
642
|
core_properties: wb_facade[:core_properties],
|
|
364
643
|
app_properties: wb_facade[:app_properties],
|
|
365
644
|
custom_properties: wb_facade[:custom_properties],
|
|
366
645
|
workbook_protection: wb_facade[:workbook_protection],
|
|
367
|
-
workbook_properties:
|
|
646
|
+
workbook_properties: wb_properties
|
|
368
647
|
)
|
|
369
648
|
end
|
|
370
649
|
end
|
|
@@ -422,7 +701,8 @@ module Xlsxrb
|
|
|
422
701
|
initial_sst_size = shared_strings.size
|
|
423
702
|
|
|
424
703
|
workbook_xml = zip_reader.read_entry("xl/workbook.xml")
|
|
425
|
-
|
|
704
|
+
wb_parsed = workbook_xml ? Ooxml::WorkbookParser.parse_with_properties(workbook_xml) : { sheets: [], date1904: false, defined_names: [] }
|
|
705
|
+
workbook_sheets = wb_parsed[:sheets]
|
|
426
706
|
|
|
427
707
|
rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
|
|
428
708
|
rels = rels_xml ? Ooxml::RelationshipsParser.parse(rels_xml) : {}
|
|
@@ -440,13 +720,14 @@ module Xlsxrb
|
|
|
440
720
|
|
|
441
721
|
sheet_entry_map[sheet_info[:name]] = sheet_path
|
|
442
722
|
sheet_xml = zip_reader.read_entry(sheet_path) || ""
|
|
443
|
-
build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles)
|
|
723
|
+
build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles, state: sheet_info[:state] || :visible, zip_reader: zip_reader, entry_name: sheet_path)
|
|
444
724
|
end.compact
|
|
445
725
|
|
|
446
726
|
original_workbook = Elements::Workbook.new(
|
|
447
727
|
sheets: loaded_sheets,
|
|
448
728
|
shared_strings: shared_strings,
|
|
449
|
-
styles: styles
|
|
729
|
+
styles: styles,
|
|
730
|
+
defined_names: wb_parsed[:defined_names] || []
|
|
450
731
|
)
|
|
451
732
|
mutable_shared_strings = shared_strings.dup
|
|
452
733
|
|
|
@@ -546,59 +827,271 @@ module Xlsxrb
|
|
|
546
827
|
class << self
|
|
547
828
|
private
|
|
548
829
|
|
|
549
|
-
#: (String name, String? sheet_xml, Array[String] shared_strings, untyped
|
|
550
|
-
def build_worksheet(name, sheet_xml, shared_strings,
|
|
551
|
-
|
|
830
|
+
#: (String name, String? sheet_xml, Array[String] shared_strings, untyped styles, ?state: Symbol, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?hyperlinks: Hash[String, Hash[Symbol, untyped]]?, ?comments: Array[Hash[Symbol, untyped]]?, ?images: Array[Elements::Image]?, ?date1904: bool, ?dimension: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) -> Elements::Worksheet
|
|
831
|
+
def build_worksheet(name, sheet_xml, shared_strings, styles, state: :visible, zip_reader: nil, entry_name: nil, hyperlinks: nil, comments: nil, images: nil, date1904: false, dimension: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false)
|
|
832
|
+
sheet_dimension = dimension || sheet_xml&.slice(/<(?:[a-zA-Z0-9_]+:)?dimension\b[^>]*\bref=["']([^"']+)["']/, 1)
|
|
833
|
+
return Elements::Worksheet.new(name: name, state: state, styles: styles, images: images || [], date1904: date1904, dimension: sheet_dimension, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) if sheet_xml.nil? || sheet_xml.empty?
|
|
552
834
|
|
|
553
|
-
|
|
835
|
+
sheet_hyperlinks = hyperlinks || resolve_hyperlinks(sheet_xml, zip_reader: zip_reader, entry_name: entry_name)
|
|
836
|
+
sheet_comments = comments || resolve_comments(zip_reader: zip_reader, entry_name: entry_name)
|
|
837
|
+
sheet_images = images || resolve_images(zip_reader: zip_reader, entry_name: entry_name)
|
|
838
|
+
raw_rows = Ooxml::WorksheetParser.parse(sheet_xml, shared_strings: shared_strings, styles: styles)
|
|
554
839
|
raw_columns = Ooxml::WorksheetParser.parse_columns(sheet_xml)
|
|
555
840
|
|
|
556
|
-
rows = raw_rows.map { |rr| build_row_from_raw(rr) }
|
|
841
|
+
rows = raw_rows.map { |rr| build_row_from_raw(rr, sheet_hyperlinks, sheet_comments, styles, date1904: date1904) }
|
|
557
842
|
columns = raw_columns.map do |rc|
|
|
558
843
|
# Columns from OOXML are 1-based min/max ranges; convert to 0-based
|
|
844
|
+
col_unmapped = {}
|
|
845
|
+
col_unmapped[:style_index] = rc[:style_index] if rc[:style_index]
|
|
559
846
|
Elements::Column.new(
|
|
560
847
|
index: (rc[:min] || 1) - 1,
|
|
561
848
|
width: rc[:width],
|
|
562
849
|
hidden: rc[:hidden] || false,
|
|
563
850
|
custom_width: rc[:custom_width] || false,
|
|
564
|
-
outline_level: rc[:outline_level]
|
|
851
|
+
outline_level: rc[:outline_level],
|
|
852
|
+
style_index: rc[:style_index],
|
|
853
|
+
unmapped_data: col_unmapped
|
|
565
854
|
)
|
|
566
855
|
end
|
|
567
856
|
|
|
568
|
-
|
|
857
|
+
cfs = if sheet_xml.include?("<conditionalFormatting")
|
|
858
|
+
listener = Ooxml::Reader::ConditionalFormattingListener.new
|
|
859
|
+
Ooxml::XmlParser.parse(sheet_xml, listener)
|
|
860
|
+
listener.rules
|
|
861
|
+
else
|
|
862
|
+
[]
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
dvs = if sheet_xml.include?("<dataValidation")
|
|
866
|
+
listener = Ooxml::Reader::DataValidationsListener.new
|
|
867
|
+
Ooxml::XmlParser.parse(sheet_xml, listener)
|
|
868
|
+
listener.validations
|
|
869
|
+
else
|
|
870
|
+
[]
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
Elements::Worksheet.new(
|
|
874
|
+
name: name,
|
|
875
|
+
rows: rows,
|
|
876
|
+
columns: columns,
|
|
877
|
+
conditional_formatting: cfs,
|
|
878
|
+
data_validations: dvs,
|
|
879
|
+
state: state,
|
|
880
|
+
hyperlinks: sheet_hyperlinks,
|
|
881
|
+
comments: sheet_comments,
|
|
882
|
+
images: sheet_images,
|
|
883
|
+
styles: styles,
|
|
884
|
+
date1904: date1904,
|
|
885
|
+
dimension: sheet_dimension,
|
|
886
|
+
trim_empty_rows: trim_empty_rows,
|
|
887
|
+
pad_empty_rows: pad_empty_rows,
|
|
888
|
+
pad_empty_cells: pad_empty_cells
|
|
889
|
+
)
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
#: (String? sheet_xml, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?rels_xml: String?) -> Hash[String, Hash[Symbol, untyped]]
|
|
893
|
+
def resolve_hyperlinks(sheet_xml, zip_reader: nil, entry_name: nil, rels_xml: nil)
|
|
894
|
+
return {} if sheet_xml.nil? || !sheet_xml.include?("hyperlink")
|
|
895
|
+
|
|
896
|
+
links = Ooxml::WorksheetParser.parse_hyperlinks(sheet_xml)
|
|
897
|
+
return {} if links.empty?
|
|
898
|
+
|
|
899
|
+
rid_to_url = {}
|
|
900
|
+
if rels_xml && !rels_xml.empty?
|
|
901
|
+
rid_to_url = Ooxml::RelationshipsParser.parse(rels_xml)
|
|
902
|
+
elsif zip_reader && entry_name
|
|
903
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
904
|
+
rels_content = zip_reader.read_entry(rels_path)
|
|
905
|
+
rid_to_url = Ooxml::RelationshipsParser.parse(rels_content) if rels_content && !rels_content.empty?
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
result = {}
|
|
909
|
+
links.each do |link|
|
|
910
|
+
entry = {}
|
|
911
|
+
if link[:rid]
|
|
912
|
+
url = rid_to_url[link[:rid]]
|
|
913
|
+
entry[:url] = url if url
|
|
914
|
+
end
|
|
915
|
+
entry[:display] = link[:display] if link[:display]
|
|
916
|
+
entry[:tooltip] = link[:tooltip] if link[:tooltip]
|
|
917
|
+
entry[:location] = link[:location] if link[:location]
|
|
918
|
+
result[link[:ref]] = entry unless entry.empty?
|
|
919
|
+
end
|
|
920
|
+
result
|
|
569
921
|
end
|
|
570
922
|
|
|
571
|
-
#: (
|
|
572
|
-
def
|
|
923
|
+
#: (String base_dir, String target) -> String
|
|
924
|
+
def resolve_relative_path(base_dir, target)
|
|
925
|
+
if target.start_with?("/")
|
|
926
|
+
target.delete_prefix("/")
|
|
927
|
+
elsif target.start_with?("..")
|
|
928
|
+
parts = base_dir.split("/") + target.split("/")
|
|
929
|
+
resolved = []
|
|
930
|
+
parts.each { |p| p == ".." ? resolved.pop : resolved << p }
|
|
931
|
+
resolved.join("/")
|
|
932
|
+
else
|
|
933
|
+
"#{base_dir}/#{target}"
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
#: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
|
|
938
|
+
def resolve_comments(zip_reader: nil, entry_name: nil)
|
|
939
|
+
return [] unless zip_reader && entry_name
|
|
940
|
+
|
|
941
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
942
|
+
rels_xml = zip_reader.read_entry(rels_path)
|
|
943
|
+
return [] if rels_xml.nil? || rels_xml.empty?
|
|
944
|
+
|
|
945
|
+
parser = REXML::Parsers::SAX2Parser.new(rels_xml)
|
|
946
|
+
listener = Ooxml::Reader::RelsListener.new
|
|
947
|
+
parser.listen(listener)
|
|
948
|
+
parser.parse
|
|
949
|
+
|
|
950
|
+
rel = listener.relationships.find { |r| r[:type]&.end_with?("/comments") }
|
|
951
|
+
return [] unless rel
|
|
952
|
+
|
|
953
|
+
base_dir = File.dirname(entry_name)
|
|
954
|
+
comments_path = resolve_relative_path(base_dir, rel[:target])
|
|
955
|
+
|
|
956
|
+
xml = zip_reader.read_entry(comments_path)
|
|
957
|
+
return [] if xml.nil? || xml.empty?
|
|
958
|
+
|
|
959
|
+
c_parser = REXML::Parsers::SAX2Parser.new(xml)
|
|
960
|
+
c_listener = Ooxml::Reader::CommentsListener.new
|
|
961
|
+
c_parser.listen(c_listener)
|
|
962
|
+
c_parser.parse
|
|
963
|
+
c_listener.comments
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
#: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Elements::Image]
|
|
967
|
+
def resolve_images(zip_reader: nil, entry_name: nil)
|
|
968
|
+
return [] unless zip_reader && entry_name
|
|
969
|
+
|
|
970
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
971
|
+
rels_xml = zip_reader.read_entry(rels_path)
|
|
972
|
+
return [] if rels_xml.nil? || rels_xml.empty?
|
|
973
|
+
|
|
974
|
+
parser = REXML::Parsers::SAX2Parser.new(rels_xml)
|
|
975
|
+
listener = Ooxml::Reader::RelsListener.new
|
|
976
|
+
parser.listen(listener)
|
|
977
|
+
parser.parse
|
|
978
|
+
|
|
979
|
+
drawing_rel = listener.relationships.find { |r| r[:type]&.end_with?("/drawing") }
|
|
980
|
+
return [] unless drawing_rel
|
|
981
|
+
|
|
982
|
+
base_dir = File.dirname(entry_name)
|
|
983
|
+
target = drawing_rel[:target]
|
|
984
|
+
drawing_path = resolve_relative_path(base_dir, target)
|
|
985
|
+
drawing_xml = zip_reader.read_entry(drawing_path)
|
|
986
|
+
return [] if drawing_xml.nil? || drawing_xml.empty?
|
|
987
|
+
|
|
988
|
+
drawing_rels_path = drawing_path.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
989
|
+
drawing_rels_xml = zip_reader.read_entry(drawing_rels_path)
|
|
990
|
+
drawing_rels = {}
|
|
991
|
+
if drawing_rels_xml && !drawing_rels_xml.empty?
|
|
992
|
+
d_parser = REXML::Parsers::SAX2Parser.new(drawing_rels_xml)
|
|
993
|
+
d_listener = Ooxml::Reader::RelsListener.new
|
|
994
|
+
d_parser.listen(d_listener)
|
|
995
|
+
d_parser.parse
|
|
996
|
+
d_listener.relationships.each do |r|
|
|
997
|
+
drawing_rels[r[:id]] = r[:target]
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
p_parser = REXML::Parsers::SAX2Parser.new(drawing_xml)
|
|
1002
|
+
img_listener = Ooxml::Reader::DrawingImagesListener.new
|
|
1003
|
+
p_parser.listen(img_listener)
|
|
1004
|
+
p_parser.parse
|
|
1005
|
+
|
|
1006
|
+
drawing_dir = File.dirname(drawing_path)
|
|
1007
|
+
img_listener.images.map do |raw_img|
|
|
1008
|
+
rid = raw_img[:embed_rid]
|
|
1009
|
+
media_target = drawing_rels[rid]
|
|
1010
|
+
img_data = nil
|
|
1011
|
+
filename = nil
|
|
1012
|
+
if media_target
|
|
1013
|
+
media_path = resolve_relative_path(drawing_dir, media_target)
|
|
1014
|
+
filename = File.basename(media_path)
|
|
1015
|
+
img_data = zip_reader.read_entry(media_path)
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
from_col = raw_img[:from_col] || 0
|
|
1019
|
+
from_row = raw_img[:from_row] || 0
|
|
1020
|
+
to_col = raw_img[:to_col]
|
|
1021
|
+
to_row = raw_img[:to_row]
|
|
1022
|
+
cx = raw_img[:cx]
|
|
1023
|
+
cy = raw_img[:cy]
|
|
1024
|
+
name = raw_img[:name]
|
|
1025
|
+
|
|
1026
|
+
Elements::Image.new(
|
|
1027
|
+
filename: filename || "image.png",
|
|
1028
|
+
name: name,
|
|
1029
|
+
data: img_data,
|
|
1030
|
+
from_col: from_col,
|
|
1031
|
+
from_row: from_row,
|
|
1032
|
+
to_col: to_col,
|
|
1033
|
+
to_row: to_row,
|
|
1034
|
+
cx: cx,
|
|
1035
|
+
cy: cy,
|
|
1036
|
+
unmapped_data: raw_img
|
|
1037
|
+
)
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
#: (untyped raw_row, ?Hash[String, Hash[Symbol, untyped]] hyperlinks, ?(Array[Hash[Symbol, untyped]] | Hash[String, Hash[Symbol, untyped]]) comments, ?Hash[untyped, untyped]? styles, ?date1904: bool) -> (Elements::Row | untyped)
|
|
1042
|
+
def build_row_from_raw(raw_row, hyperlinks = {}, comments = [], styles = nil, date1904: false)
|
|
573
1043
|
return raw_row if raw_row.is_a?(Elements::Row)
|
|
574
1044
|
|
|
1045
|
+
comments_map = if comments.is_a?(Hash)
|
|
1046
|
+
comments
|
|
1047
|
+
else
|
|
1048
|
+
comments.each_with_object({}) { |c, acc| (r = c[:ref] || c[:cell]) && (acc[r.to_s.upcase] = c) }
|
|
1049
|
+
end
|
|
1050
|
+
|
|
575
1051
|
raw_cells = raw_row[:cells]
|
|
576
|
-
cells =
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1052
|
+
cells = raw_cells.map do |rc|
|
|
1053
|
+
if rc.is_a?(Elements::Cell)
|
|
1054
|
+
hl = hyperlinks && !hyperlinks.empty? ? hyperlinks[rc.ref] : nil
|
|
1055
|
+
cm = comments_map && !comments_map.empty? ? comments_map[rc.ref] : nil
|
|
1056
|
+
fmt = rc.format_code || (NumberFormatter.format_code_for(rc.style_index, styles) if styles && rc.style_index)
|
|
1057
|
+
if hl || cm || (fmt && rc.format_code != fmt) || (date1904 && !rc.date1904?)
|
|
1058
|
+
rc.with(hyperlink: hl || rc.hyperlink, comment: cm || rc.comment, format_code: fmt || rc.format_code, date1904: date1904 || rc.date1904?)
|
|
1059
|
+
else
|
|
1060
|
+
rc
|
|
1061
|
+
end
|
|
1062
|
+
else
|
|
1063
|
+
parsed = Elements::Cell.parse_ref(rc[:ref]) if rc[:ref]
|
|
1064
|
+
row_idx = parsed ? parsed[0] : raw_row[:index]
|
|
1065
|
+
col_idx = parsed ? parsed[1] : 0
|
|
1066
|
+
ref = rc[:ref] || "#{Elements::Cell.column_letter(col_idx)}#{row_idx + 1}"
|
|
1067
|
+
hl = hyperlinks ? hyperlinks[ref] : nil
|
|
1068
|
+
cm = comments_map ? comments_map[ref] : nil
|
|
1069
|
+
fmt = rc[:format_code] || (NumberFormatter.format_code_for(rc[:style_index], styles) if styles && rc[:style_index])
|
|
1070
|
+
|
|
1071
|
+
val = rc[:value]
|
|
1072
|
+
raw_val = rc[:raw_value] || val&.to_s
|
|
1073
|
+
cell_errors = Elements::Cell.validate(row_idx, col_idx, val)
|
|
1074
|
+
if !cell_errors.empty? && rc[:source]
|
|
1075
|
+
cell_errors = cell_errors.map do |err|
|
|
1076
|
+
"#{err} (at #{rc[:source][:part]} row #{rc[:source][:row] + 1} cell #{rc[:ref] || "unknown"})"
|
|
1077
|
+
end
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
Elements::Cell.new(
|
|
1081
|
+
row_index: row_idx,
|
|
1082
|
+
column_index: col_idx,
|
|
1083
|
+
value: val,
|
|
1084
|
+
formula: rc[:formula],
|
|
1085
|
+
style_index: rc[:style_index],
|
|
1086
|
+
hyperlink: hl,
|
|
1087
|
+
comment: cm,
|
|
1088
|
+
raw_value: raw_val,
|
|
1089
|
+
format_code: fmt,
|
|
1090
|
+
errors: cell_errors,
|
|
1091
|
+
date1904: date1904
|
|
1092
|
+
)
|
|
1093
|
+
end
|
|
1094
|
+
end
|
|
602
1095
|
attrs = raw_row[:attrs] || {}
|
|
603
1096
|
row_errors = Elements::Row.validate(raw_row[:index], cells)
|
|
604
1097
|
if !row_errors.empty? && raw_row[:source]
|
|
@@ -609,19 +1102,23 @@ module Xlsxrb
|
|
|
609
1102
|
# simplecov:enable
|
|
610
1103
|
end
|
|
611
1104
|
end
|
|
1105
|
+
row_unmapped = {}
|
|
1106
|
+
row_unmapped[:style_index] = attrs[:style_index] if attrs[:style_index]
|
|
612
1107
|
Elements::Row.new(
|
|
613
1108
|
index: raw_row[:index],
|
|
614
1109
|
cells: cells,
|
|
615
1110
|
height: attrs[:height],
|
|
616
1111
|
hidden: attrs[:hidden] || false,
|
|
617
1112
|
custom_height: attrs[:custom_height] || false,
|
|
1113
|
+
collapsed: attrs[:collapsed] || false,
|
|
618
1114
|
outline_level: attrs[:outline_level],
|
|
1115
|
+
unmapped_data: row_unmapped,
|
|
619
1116
|
errors: row_errors
|
|
620
1117
|
)
|
|
621
1118
|
end
|
|
622
1119
|
|
|
623
|
-
#: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index) -> Hash[Symbol, untyped]
|
|
624
|
-
def build_raw_cell(cell, sst, sst_index)
|
|
1120
|
+
#: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
|
|
1121
|
+
def build_raw_cell(cell, sst, sst_index, date1904: false)
|
|
625
1122
|
# simplecov:disable
|
|
626
1123
|
# Edge case / untested delegation block
|
|
627
1124
|
ref = cell.ref
|
|
@@ -645,9 +1142,9 @@ module Xlsxrb
|
|
|
645
1142
|
result[:value] = value.code
|
|
646
1143
|
result[:type] = "e"
|
|
647
1144
|
when Date
|
|
648
|
-
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
|
|
1145
|
+
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
|
|
649
1146
|
when Time
|
|
650
|
-
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
|
|
1147
|
+
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
|
|
651
1148
|
# simplecov:enable
|
|
652
1149
|
when NilClass
|
|
653
1150
|
# empty cell
|
|
@@ -687,6 +1184,7 @@ module Xlsxrb
|
|
|
687
1184
|
attrs[:height] = row.height if row.height
|
|
688
1185
|
attrs[:hidden] = true if row.hidden
|
|
689
1186
|
attrs[:custom_height] = true if row.custom_height
|
|
1187
|
+
attrs[:collapsed] = true if row.collapsed
|
|
690
1188
|
attrs[:outline_level] = row.outline_level if row.outline_level
|
|
691
1189
|
attrs
|
|
692
1190
|
# simplecov:enable
|
|
@@ -695,12 +1193,13 @@ module Xlsxrb
|
|
|
695
1193
|
#: (untyped source, String? password) -> untyped
|
|
696
1194
|
def prepare_source_io(source, password)
|
|
697
1195
|
if source.is_a?(String)
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1196
|
+
bin_source = source.encoding == Encoding::BINARY ? source : source.b
|
|
1197
|
+
if bin_source.start_with?("PK\x03\x04".b) || bin_source.include?("\x00".b) || Ooxml::Cfb::Reader.cfb?(bin_source)
|
|
1198
|
+
if Ooxml::Cfb::Reader.cfb?(bin_source)
|
|
1199
|
+
decrypted_zip = Ooxml::Crypto.decrypt(bin_source, password)
|
|
701
1200
|
StringIO.new(decrypted_zip)
|
|
702
1201
|
else
|
|
703
|
-
StringIO.new(
|
|
1202
|
+
StringIO.new(bin_source)
|
|
704
1203
|
end
|
|
705
1204
|
elsif File.file?(source)
|
|
706
1205
|
first_bytes = begin
|
|
@@ -861,11 +1360,11 @@ module Xlsxrb
|
|
|
861
1360
|
sheet_xml.sub(/<dimension\b([^>]*)\bref="[^"]*"/, "<dimension\\1ref=\"#{dim_ref}\"")
|
|
862
1361
|
end
|
|
863
1362
|
|
|
864
|
-
#: (Array[Elements::Row] rows, Array[untyped] shared_strings, Hash[untyped, Integer] sst_index, ?use_sst: bool) -> String
|
|
865
|
-
def serialize_sheet_data_xml(rows, shared_strings, sst_index, use_sst: true)
|
|
1363
|
+
#: (Array[Elements::Row] rows, Array[untyped] shared_strings, Hash[untyped, Integer] sst_index, ?use_sst: bool, ?date1904: bool) -> String
|
|
1364
|
+
def serialize_sheet_data_xml(rows, shared_strings, sst_index, use_sst: true, date1904: false)
|
|
866
1365
|
io = StringIO.new
|
|
867
1366
|
io.write("<sheetData>")
|
|
868
|
-
ws = Ooxml::WorksheetWriter.new(io)
|
|
1367
|
+
ws = Ooxml::WorksheetWriter.new(io, date1904: date1904)
|
|
869
1368
|
ws.instance_variable_set(:@started, true)
|
|
870
1369
|
|
|
871
1370
|
rows.each do |row|
|
|
@@ -915,8 +1414,8 @@ module Xlsxrb
|
|
|
915
1414
|
# @param sst_index [Hash{String => Integer}] Shared strings index mapping.
|
|
916
1415
|
# @return [Hash{Symbol => Object}] Raw cell hash.
|
|
917
1416
|
# @api public
|
|
918
|
-
#: (Integer row_index, Integer col_index, untyped value, Array[String] sst, Hash[String, Integer] sst_index) -> Hash[Symbol, untyped]
|
|
919
|
-
def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index)
|
|
1417
|
+
#: (Integer row_index, Integer col_index, untyped value, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
|
|
1418
|
+
def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index, date1904: false)
|
|
920
1419
|
# simplecov:disable
|
|
921
1420
|
# Edge case / untested delegation block
|
|
922
1421
|
ref = "#{Elements::Cell.column_letter(col_index)}#{row_index + 1}"
|
|
@@ -943,9 +1442,9 @@ module Xlsxrb
|
|
|
943
1442
|
result[:value] = value.code
|
|
944
1443
|
result[:type] = "e"
|
|
945
1444
|
when Date
|
|
946
|
-
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
|
|
1445
|
+
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
|
|
947
1446
|
when Time
|
|
948
|
-
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
|
|
1447
|
+
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
|
|
949
1448
|
# simplecov:enable
|
|
950
1449
|
when NilClass
|
|
951
1450
|
# empty cell
|