xlsxrb 0.1.13 → 0.1.15
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 +104 -0
- data/README.md +17 -14
- 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 +121 -4
- data/lib/xlsxrb/elements/worksheet.rb +519 -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/listeners/feature_listeners.rb +7 -1
- data/lib/xlsxrb/ooxml/reader.rb +14 -17
- 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 +74 -16
- data/lib/xlsxrb/ooxml/workbook_writer.rb +82 -23
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +319 -79
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +263 -66
- data/lib/xlsxrb/ooxml/writer/features_xml.rb +5 -3
- data/lib/xlsxrb/ooxml/writer.rb +14 -0
- data/lib/xlsxrb/ooxml/xml_builder.rb +14 -2
- data/lib/xlsxrb/ooxml/zip_reader.rb +144 -28
- data/lib/xlsxrb/stream_row.rb +243 -16
- data/lib/xlsxrb/stream_sheet.rb +647 -16
- data/lib/xlsxrb/stream_writer.rb +375 -34
- data/lib/xlsxrb/style_builder.rb +308 -22
- data/lib/xlsxrb/utils.rb +211 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +72 -11
- data/lib/xlsxrb/worksheet_builder.rb +738 -24
- data/lib/xlsxrb.rb +1073 -83
- 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 +79 -2
- data/sig/generated/xlsxrb/elements/worksheet.rbs +282 -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/workbook_writer.rbs +1 -1
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +28 -6
- data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +13 -3
- data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +3 -1
- data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +13 -0
- data/sig/generated/xlsxrb/stream_row.rbs +100 -6
- data/sig/generated/xlsxrb/stream_sheet.rbs +358 -14
- data/sig/generated/xlsxrb/stream_writer.rbs +220 -30
- data/sig/generated/xlsxrb/style_builder.rbs +55 -18
- data/sig/generated/xlsxrb/utils.rbs +151 -0
- data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
- data/sig/generated/xlsxrb/worksheet_builder.rbs +356 -18
- data/sig/generated/xlsxrb.rbs +223 -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,225 @@ 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
|
+
# Converts a cell range reference string (e.g. "A1:C10", "A1") to 0-based [[top_row, left_col], [bottom_row, right_col]].
|
|
282
|
+
#
|
|
283
|
+
# @example
|
|
284
|
+
# Xlsxrb.parse_range("A1:C10") #=> [[0, 0], [9, 2]]
|
|
285
|
+
#
|
|
286
|
+
# @param range_ref [String, nil]
|
|
287
|
+
# @return [Array([Integer, Integer], [Integer, Integer]), nil]
|
|
288
|
+
# @api public
|
|
289
|
+
#: (String? range_ref) -> [[Integer, Integer], [Integer, Integer]]?
|
|
290
|
+
def self.parse_range(range_ref)
|
|
291
|
+
Utils.parse_range(range_ref)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Converts 0-based bounding coordinates to an A1-style range reference string.
|
|
295
|
+
#
|
|
296
|
+
# @example
|
|
297
|
+
# Xlsxrb.range_to_ref(0, 0, 9, 2) #=> "A1:C10"
|
|
298
|
+
#
|
|
299
|
+
# @param top_row [Integer]
|
|
300
|
+
# @param left_col [Integer]
|
|
301
|
+
# @param bottom_row [Integer]
|
|
302
|
+
# @param right_col [Integer]
|
|
303
|
+
# @return [String]
|
|
304
|
+
# @api public
|
|
305
|
+
#: (Integer top_row, Integer left_col, Integer bottom_row, Integer right_col) -> String
|
|
306
|
+
def self.range_to_ref(top_row, left_col, bottom_row, right_col)
|
|
307
|
+
Utils.range_to_ref(top_row, left_col, bottom_row, right_col)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Resolves an ECMA-376 indexed color palette index (0..63) to an RRGGBB hex string (or AARRGGBB if alpha: true).
|
|
311
|
+
#
|
|
312
|
+
# @example
|
|
313
|
+
# Xlsxrb.palette_color(12) #=> "0000FF"
|
|
314
|
+
# Xlsxrb.palette_color(12, alpha: true) #=> "FF0000FF"
|
|
315
|
+
#
|
|
316
|
+
# @param index [Object, nil] Palette index (0..63).
|
|
317
|
+
# @param alpha [Boolean] Whether to prefix "FF" alpha channel.
|
|
318
|
+
# @return [String, nil]
|
|
319
|
+
# @api public
|
|
320
|
+
#: (untyped index, ?alpha: bool) -> String?
|
|
321
|
+
def self.palette_color(index, alpha: false)
|
|
322
|
+
Colors.palette_color(index, alpha: alpha)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Converts any supported color representation (palette index 0..63, CSS name, #RGB, #RRGGBB, #AARRGGBB, integer RGB)
|
|
326
|
+
# to a normalized hex string.
|
|
327
|
+
#
|
|
328
|
+
# @example
|
|
329
|
+
# Xlsxrb.to_hex_color(12) #=> "0000FF"
|
|
330
|
+
# Xlsxrb.to_hex_color(:navy) #=> "000080"
|
|
331
|
+
# Xlsxrb.to_hex_color("#FF0000") #=> "FF0000"
|
|
332
|
+
#
|
|
333
|
+
# @param color [Integer, String, Symbol, nil]
|
|
334
|
+
# @param alpha [Boolean] Whether to include the alpha channel (default false -> "RRGGBB").
|
|
335
|
+
# @return [String, nil]
|
|
336
|
+
# @api public
|
|
337
|
+
#: (Integer | String | Symbol | nil color, ?alpha: bool) -> String?
|
|
338
|
+
def self.to_hex_color(color, alpha: false)
|
|
339
|
+
Colors.to_hex(color, alpha: alpha)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
class << self
|
|
343
|
+
alias color_to_hex to_hex_color
|
|
344
|
+
end
|
|
345
|
+
|
|
118
346
|
# Reads an XLSX file (streaming and lazy-loaded by default) from a file path, IO stream, or binary String.
|
|
119
347
|
#
|
|
120
348
|
# Sheets and rows are streamed lazily with O(1) constant memory. If a block is given,
|
|
121
349
|
# yields each {StreamSheet} sequentially. Call {#load} on the returned Workbook or Sheet
|
|
122
350
|
# to convert to an in-memory representation for coordinate random access (`sheet["A1"]`).
|
|
123
351
|
#
|
|
124
|
-
# @overload read(source, password: nil, &block)
|
|
352
|
+
# @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &block)
|
|
125
353
|
# Yields each {StreamSheet} sequentially in streaming mode.
|
|
126
354
|
# @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
|
|
127
355
|
# @param password [String, nil] Optional password to decrypt password-protected XLSX files.
|
|
356
|
+
# @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
|
|
357
|
+
# @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
|
|
358
|
+
# @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
|
|
128
359
|
# @yield [sheet]
|
|
129
360
|
# @yieldparam sheet [StreamSheet] The streaming worksheet object.
|
|
130
361
|
# @return [void]
|
|
131
362
|
#
|
|
132
|
-
# @overload read(source, password: nil)
|
|
363
|
+
# @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false)
|
|
133
364
|
# Returns a lazy {Elements::Workbook} object.
|
|
134
365
|
# @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
|
|
135
366
|
# @param password [String, nil] Optional password to decrypt password-protected XLSX files.
|
|
367
|
+
# @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
|
|
368
|
+
# @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
|
|
369
|
+
# @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
|
|
136
370
|
# @return [Elements::Workbook]
|
|
137
371
|
#
|
|
138
372
|
# @example Streaming read across sheets and rows (O(1) constant memory)
|
|
@@ -153,9 +387,9 @@ module Xlsxrb
|
|
|
153
387
|
# @raise [InvalidPasswordError] If the supplied password is incorrect.
|
|
154
388
|
# @raise [ParseError] If the spreadsheet XML structure is invalid.
|
|
155
389
|
# @api public
|
|
156
|
-
#: (untyped source, ?password: String?) { (StreamSheet) -> void } -> void
|
|
157
|
-
#: (untyped source, ?password: String?) -> Elements::Workbook
|
|
158
|
-
def self.read(source, password: nil, &)
|
|
390
|
+
#: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) { (StreamSheet) -> void } -> void
|
|
391
|
+
#: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) -> Elements::Workbook
|
|
392
|
+
def self.read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &)
|
|
159
393
|
prepared_source = prepare_source_io(source, password)
|
|
160
394
|
zip_reader = Ooxml::ZipReader.open(prepared_source)
|
|
161
395
|
begin
|
|
@@ -163,7 +397,9 @@ module Xlsxrb
|
|
|
163
397
|
shared_strings = Ooxml::SharedStringsParser.parse(shared_strings_xml)
|
|
164
398
|
|
|
165
399
|
workbook_xml = zip_reader.read_entry("xl/workbook.xml")
|
|
166
|
-
|
|
400
|
+
wb_parsed = Ooxml::WorkbookParser.parse_with_properties(workbook_xml)
|
|
401
|
+
workbook_sheets = wb_parsed[:sheets]
|
|
402
|
+
is_date1904 = wb_parsed[:date1904]
|
|
167
403
|
|
|
168
404
|
rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
|
|
169
405
|
rels = Ooxml::RelationshipsParser.parse(rels_xml)
|
|
@@ -186,11 +422,56 @@ module Xlsxrb
|
|
|
186
422
|
shared_strings,
|
|
187
423
|
styles,
|
|
188
424
|
zip_reader: zip_reader,
|
|
189
|
-
entry_name: sheet_path
|
|
425
|
+
entry_name: sheet_path,
|
|
426
|
+
state: sheet_info[:state] || :visible,
|
|
427
|
+
date1904: is_date1904,
|
|
428
|
+
trim_empty_rows: trim_empty_rows,
|
|
429
|
+
pad_empty_rows: pad_empty_rows,
|
|
430
|
+
pad_empty_cells: pad_empty_cells
|
|
190
431
|
)
|
|
191
432
|
end.compact
|
|
192
433
|
|
|
193
|
-
|
|
434
|
+
wb_unmapped = {}
|
|
435
|
+
wb_unmapped[:workbook_properties] = { date1904: true } if is_date1904
|
|
436
|
+
|
|
437
|
+
if workbook_xml&.include?("<workbookProtection")
|
|
438
|
+
prot_match = workbook_xml.match(%r{<workbookProtection\b([^>]*)/>})
|
|
439
|
+
if prot_match
|
|
440
|
+
prot = {}
|
|
441
|
+
prot[:lock_structure] = true if prot_match[1].include?('lockStructure="1"') || prot_match[1].include?('lockStructure="true"')
|
|
442
|
+
prot[:lock_windows] = true if prot_match[1].include?('lockWindows="1"') || prot_match[1].include?('lockWindows="true"')
|
|
443
|
+
wb_unmapped[:workbook_protection] = prot unless prot.empty?
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
core_xml = zip_reader.read_entry("docProps/core.xml")
|
|
448
|
+
if core_xml && !core_xml.empty?
|
|
449
|
+
c_listener = Ooxml::Reader::CorePropertiesListener.new
|
|
450
|
+
Ooxml::XmlParser.parse(core_xml, c_listener)
|
|
451
|
+
wb_unmapped[:core_properties] = c_listener.properties unless c_listener.properties.empty?
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
app_xml = zip_reader.read_entry("docProps/app.xml")
|
|
455
|
+
if app_xml && !app_xml.empty?
|
|
456
|
+
a_listener = Ooxml::Reader::AppPropertiesListener.new
|
|
457
|
+
Ooxml::XmlParser.parse(app_xml, a_listener)
|
|
458
|
+
wb_unmapped[:app_properties] = a_listener.properties unless a_listener.properties.empty?
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
custom_xml = zip_reader.read_entry("docProps/custom.xml")
|
|
462
|
+
if custom_xml && !custom_xml.empty?
|
|
463
|
+
cu_listener = Ooxml::Reader::CustomPropertiesListener.new
|
|
464
|
+
Ooxml::XmlParser.parse(custom_xml, cu_listener)
|
|
465
|
+
wb_unmapped[:custom_properties] = cu_listener.properties unless cu_listener.properties.empty?
|
|
466
|
+
end
|
|
467
|
+
defined_names = wb_parsed[:defined_names] || []
|
|
468
|
+
wb = Elements::Workbook.new(
|
|
469
|
+
sheets: sheets,
|
|
470
|
+
shared_strings: shared_strings,
|
|
471
|
+
styles: styles,
|
|
472
|
+
unmapped_data: wb_unmapped,
|
|
473
|
+
defined_names: defined_names
|
|
474
|
+
)
|
|
194
475
|
|
|
195
476
|
if block_given?
|
|
196
477
|
sheets.each(&)
|
|
@@ -203,17 +484,22 @@ module Xlsxrb
|
|
|
203
484
|
end
|
|
204
485
|
end
|
|
205
486
|
|
|
487
|
+
class << self
|
|
488
|
+
alias stream read
|
|
489
|
+
end
|
|
490
|
+
|
|
206
491
|
# Writes an XLSX file or IO stream (streaming or in-memory), or returns a binary string.
|
|
207
492
|
#
|
|
208
|
-
# @overload write(target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
|
|
493
|
+
# @overload write(?target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
|
|
209
494
|
# Streaming write: yields a {StreamWriter} context for high-speed, zero-allocation XLSX generation.
|
|
210
|
-
#
|
|
495
|
+
# If target is omitted, returns the generated XLSX as an in-memory binary String.
|
|
496
|
+
# @param target [String, IO, StringIO, nil] Optional destination file path or writable IO object.
|
|
211
497
|
# @param password [String, nil] Optional password to encrypt the generated XLSX file.
|
|
212
498
|
# @param encryption_mode [Symbol] Encryption algorithm (:standard or :agile).
|
|
213
499
|
# @param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specification limits.
|
|
214
500
|
# @yield [stream_writer]
|
|
215
501
|
# @yieldparam stream_writer [Xlsxrb::StreamWriter]
|
|
216
|
-
# @return [void]
|
|
502
|
+
# @return [String, void] Binary data if target is omitted, or void if written to target.
|
|
217
503
|
#
|
|
218
504
|
# @overload write(workbook, password: nil, encryption_mode: :standard)
|
|
219
505
|
# In-memory write: exports the workbook to an in-memory binary String.
|
|
@@ -235,6 +521,11 @@ module Xlsxrb
|
|
|
235
521
|
# writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
|
|
236
522
|
# end
|
|
237
523
|
#
|
|
524
|
+
# @example Streaming write to in-memory binary string (target omitted)
|
|
525
|
+
# binary_data = Xlsxrb.write do |writer|
|
|
526
|
+
# writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
|
|
527
|
+
# end
|
|
528
|
+
#
|
|
238
529
|
# @example Password-protected streaming write
|
|
239
530
|
# Xlsxrb.write("protected.xlsx", password: "SecretPassword123") do |writer|
|
|
240
531
|
# writer.sheet("Confidential") { |s| s.row(["Private Data", 100]) }
|
|
@@ -246,9 +537,16 @@ module Xlsxrb
|
|
|
246
537
|
# @api public
|
|
247
538
|
#: (Elements::Workbook workbook, ?password: String?, ?encryption_mode: Symbol) -> String
|
|
248
539
|
#: (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, &
|
|
540
|
+
#: (?untyped target_or_workbook, ?Elements::Workbook | untyped workbook_or_nil, ?password: String?, ?encryption_mode: Symbol, ?strict_excel_mode: bool) ?{ (StreamWriter) -> void } -> untyped
|
|
541
|
+
def self.write(target_or_workbook = OMITTED_TARGET, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &)
|
|
251
542
|
if block_given?
|
|
543
|
+
if target_or_workbook.equal?(OMITTED_TARGET)
|
|
544
|
+
io = StringIO.new
|
|
545
|
+
io.binmode
|
|
546
|
+
write(io, password: password, encryption_mode: encryption_mode, strict_excel_mode: strict_excel_mode, &)
|
|
547
|
+
return io.string.b
|
|
548
|
+
end
|
|
549
|
+
|
|
252
550
|
target = target_or_workbook
|
|
253
551
|
raise Error, "target is required" if target.nil?
|
|
254
552
|
|
|
@@ -282,6 +580,8 @@ module Xlsxrb
|
|
|
282
580
|
end
|
|
283
581
|
|
|
284
582
|
if workbook_or_nil.nil?
|
|
583
|
+
raise Error, "workbook must be an Elements::Workbook" if target_or_workbook.equal?(OMITTED_TARGET)
|
|
584
|
+
|
|
285
585
|
wb = target_or_workbook
|
|
286
586
|
raise Error, "workbook must be an Elements::Workbook" unless wb.is_a?(Elements::Workbook)
|
|
287
587
|
|
|
@@ -299,6 +599,15 @@ module Xlsxrb
|
|
|
299
599
|
sst = []
|
|
300
600
|
sst_index = {}
|
|
301
601
|
|
|
602
|
+
if workbook.shared_strings && !workbook.shared_strings.empty?
|
|
603
|
+
workbook.shared_strings.each do |val|
|
|
604
|
+
if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
|
|
605
|
+
sst << val
|
|
606
|
+
sst_index[val] = sst.size - 1
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
302
611
|
# Collect shared strings and build index without allocating new Hashes
|
|
303
612
|
sheet_data = workbook.sheets.map do |raw_ws|
|
|
304
613
|
ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
|
|
@@ -314,21 +623,63 @@ module Xlsxrb
|
|
|
314
623
|
columns = ws.columns.map do |col|
|
|
315
624
|
# simplecov:disable
|
|
316
625
|
# Edge case / untested delegation block
|
|
317
|
-
|
|
626
|
+
style_idx = col.unmapped_data&.[](:style_index) || col.unmapped_data&.[](:style) || (col.respond_to?(:style_index) && col.style_index)
|
|
627
|
+
{ index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level, style_index: style_idx }
|
|
318
628
|
# simplecov:enable
|
|
319
629
|
end
|
|
320
630
|
sd = { name: ws.name, rows: ws.rows, columns: columns }
|
|
321
631
|
sd[:charts] = ws.charts unless ws.charts.empty?
|
|
632
|
+
sd[:conditional_formats] = ws.conditional_formatting unless ws.conditional_formatting.empty?
|
|
633
|
+
sd[:data_validations] = ws.data_validations unless ws.data_validations.empty?
|
|
322
634
|
|
|
323
635
|
# Extract facade metadata from unmapped_data
|
|
324
636
|
facade = ws.unmapped_data[:facade]
|
|
325
|
-
facade&.each { |key, val| sd[key]
|
|
637
|
+
facade&.each { |key, val| sd[key] ||= val }
|
|
638
|
+
|
|
639
|
+
sd[:state] ||= ws.state if ws.respond_to?(:state) && ws.state && ws.state != :visible
|
|
640
|
+
sd[:comments] ||= ws.comments if ws.respond_to?(:comments) && !ws.comments.empty?
|
|
641
|
+
if ws.respond_to?(:hyperlinks) && !ws.hyperlinks.empty?
|
|
642
|
+
sd[:hyperlinks] ||= if ws.hyperlinks.is_a?(Hash)
|
|
643
|
+
ws.hyperlinks.map do |cell_ref, link|
|
|
644
|
+
entry = link.is_a?(Hash) ? link.dup : { url: link }
|
|
645
|
+
entry[:ref] ||= cell_ref
|
|
646
|
+
entry
|
|
647
|
+
end
|
|
648
|
+
else
|
|
649
|
+
ws.hyperlinks
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
sd[:sparkline_groups] ||= ws.sparkline_groups if ws.respond_to?(:sparkline_groups) && !ws.sparkline_groups.empty?
|
|
653
|
+
sd[:sheet_properties] ||= ws.sheet_properties if ws.respond_to?(:sheet_properties) && !ws.sheet_properties.empty?
|
|
654
|
+
sd[:sheet_format] ||= ws.sheet_format if ws.respond_to?(:sheet_format) && !ws.sheet_format.empty?
|
|
655
|
+
sd[:protected_ranges] ||= ws.protected_ranges if ws.respond_to?(:protected_ranges) && !ws.protected_ranges.empty?
|
|
656
|
+
sd[:cell_watches] ||= ws.cell_watches if ws.respond_to?(:cell_watches) && !ws.cell_watches.empty?
|
|
326
657
|
|
|
327
658
|
sd
|
|
328
659
|
end
|
|
329
660
|
|
|
330
661
|
# Extract workbook-level facade metadata
|
|
331
662
|
wb_facade = workbook.unmapped_data[:facade] || {}
|
|
663
|
+
wb_properties = workbook.unmapped_data[:workbook_properties] || wb_facade[:workbook_properties]
|
|
664
|
+
raw_dns = wb_facade[:defined_names] || (workbook.respond_to?(:defined_names) && !workbook.defined_names.empty? ? workbook.defined_names : nil)
|
|
665
|
+
defined_names = raw_dns ? raw_dns.map(&:dup) : []
|
|
666
|
+
|
|
667
|
+
# Auto-generate _xlnm.Print_Area and _xlnm.Print_Titles from worksheet definitions if not already present
|
|
668
|
+
workbook.sheets.each_with_index do |ws, idx|
|
|
669
|
+
sheet_name = ws.name || "Sheet#{idx + 1}"
|
|
670
|
+
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) }
|
|
671
|
+
val = "'#{sheet_name}'!#{DslHelpers.absolute_range(ws.print_area)}"
|
|
672
|
+
defined_names << { name: "_xlnm.Print_Area", value: val, local_sheet_id: idx }
|
|
673
|
+
end
|
|
674
|
+
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) }
|
|
675
|
+
|
|
676
|
+
pt = ws.print_titles
|
|
677
|
+
parts = []
|
|
678
|
+
parts << "'#{sheet_name}'!$#{pt[:cols].sub(":", ":$")}" if pt[:cols]
|
|
679
|
+
parts << "'#{sheet_name}'!$#{pt[:rows].sub(":", ":$")}" if pt[:rows]
|
|
680
|
+
defined_names << { name: "_xlnm.Print_Titles", value: parts.join(","), local_sheet_id: idx } unless parts.empty?
|
|
681
|
+
end
|
|
682
|
+
defined_names = nil if defined_names.empty?
|
|
332
683
|
|
|
333
684
|
if password && !password.empty?
|
|
334
685
|
buf = StringIO.new
|
|
@@ -339,12 +690,12 @@ module Xlsxrb
|
|
|
339
690
|
shared_strings: sst,
|
|
340
691
|
shared_strings_index: sst_index,
|
|
341
692
|
styles: workbook.styles,
|
|
342
|
-
defined_names:
|
|
693
|
+
defined_names: defined_names,
|
|
343
694
|
core_properties: wb_facade[:core_properties],
|
|
344
695
|
app_properties: wb_facade[:app_properties],
|
|
345
696
|
custom_properties: wb_facade[:custom_properties],
|
|
346
697
|
workbook_protection: wb_facade[:workbook_protection],
|
|
347
|
-
workbook_properties:
|
|
698
|
+
workbook_properties: wb_properties
|
|
348
699
|
)
|
|
349
700
|
encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
|
|
350
701
|
if target.is_a?(String)
|
|
@@ -359,12 +710,12 @@ module Xlsxrb
|
|
|
359
710
|
shared_strings: sst,
|
|
360
711
|
shared_strings_index: sst_index,
|
|
361
712
|
styles: workbook.styles,
|
|
362
|
-
defined_names:
|
|
713
|
+
defined_names: defined_names,
|
|
363
714
|
core_properties: wb_facade[:core_properties],
|
|
364
715
|
app_properties: wb_facade[:app_properties],
|
|
365
716
|
custom_properties: wb_facade[:custom_properties],
|
|
366
717
|
workbook_protection: wb_facade[:workbook_protection],
|
|
367
|
-
workbook_properties:
|
|
718
|
+
workbook_properties: wb_properties
|
|
368
719
|
)
|
|
369
720
|
end
|
|
370
721
|
end
|
|
@@ -422,7 +773,8 @@ module Xlsxrb
|
|
|
422
773
|
initial_sst_size = shared_strings.size
|
|
423
774
|
|
|
424
775
|
workbook_xml = zip_reader.read_entry("xl/workbook.xml")
|
|
425
|
-
|
|
776
|
+
wb_parsed = workbook_xml ? Ooxml::WorkbookParser.parse_with_properties(workbook_xml) : { sheets: [], date1904: false, defined_names: [] }
|
|
777
|
+
workbook_sheets = wb_parsed[:sheets]
|
|
426
778
|
|
|
427
779
|
rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
|
|
428
780
|
rels = rels_xml ? Ooxml::RelationshipsParser.parse(rels_xml) : {}
|
|
@@ -440,13 +792,14 @@ module Xlsxrb
|
|
|
440
792
|
|
|
441
793
|
sheet_entry_map[sheet_info[:name]] = sheet_path
|
|
442
794
|
sheet_xml = zip_reader.read_entry(sheet_path) || ""
|
|
443
|
-
build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles)
|
|
795
|
+
build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles, state: sheet_info[:state] || :visible, zip_reader: zip_reader, entry_name: sheet_path)
|
|
444
796
|
end.compact
|
|
445
797
|
|
|
446
798
|
original_workbook = Elements::Workbook.new(
|
|
447
799
|
sheets: loaded_sheets,
|
|
448
800
|
shared_strings: shared_strings,
|
|
449
|
-
styles: styles
|
|
801
|
+
styles: styles,
|
|
802
|
+
defined_names: wb_parsed[:defined_names] || []
|
|
450
803
|
)
|
|
451
804
|
mutable_shared_strings = shared_strings.dup
|
|
452
805
|
|
|
@@ -475,6 +828,51 @@ module Xlsxrb
|
|
|
475
828
|
)
|
|
476
829
|
updated_sheet_xml = replace_sheet_data(orig_sheet_xml, new_sheet_data)
|
|
477
830
|
updated_sheet_xml = update_dimension(updated_sheet_xml, new_sheet.rows)
|
|
831
|
+
|
|
832
|
+
all_hyperlinks = new_sheet.hyperlinks.dup
|
|
833
|
+
new_sheet.rows.each do |row|
|
|
834
|
+
row.cells.each do |cell|
|
|
835
|
+
all_hyperlinks[cell.ref] ||= cell.hyperlink if cell.hyperlink
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
if all_hyperlinks != orig_sheet.hyperlinks
|
|
840
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
841
|
+
orig_rels_xml = replaced_entries[rels_path] || zip_reader.read_entry(rels_path)
|
|
842
|
+
updated_sheet_xml, updated_rels_xml = update_sheet_hyperlinks(
|
|
843
|
+
updated_sheet_xml,
|
|
844
|
+
orig_rels_xml,
|
|
845
|
+
all_hyperlinks
|
|
846
|
+
)
|
|
847
|
+
replaced_entries[rels_path] = updated_rels_xml if updated_rels_xml
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
all_comments = new_sheet.comments.dup
|
|
851
|
+
new_sheet.rows.each do |row|
|
|
852
|
+
row.cells.each do |cell|
|
|
853
|
+
if cell.comment && all_comments.none? { |c| (c[:ref] || c[:cell])&.to_s&.upcase == cell.ref.upcase }
|
|
854
|
+
entry = cell.comment.is_a?(Hash) ? cell.comment.merge(ref: cell.ref) : { ref: cell.ref, text: cell.comment }
|
|
855
|
+
all_comments << entry
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
if all_comments != orig_sheet.comments
|
|
861
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
862
|
+
orig_rels_xml = replaced_entries[rels_path] || zip_reader.read_entry(rels_path)
|
|
863
|
+
updated_sheet_xml, updated_rels_xml = update_sheet_comments(
|
|
864
|
+
updated_sheet_xml,
|
|
865
|
+
rels_path,
|
|
866
|
+
orig_rels_xml,
|
|
867
|
+
all_comments,
|
|
868
|
+
zip_reader,
|
|
869
|
+
idx,
|
|
870
|
+
entry_name,
|
|
871
|
+
replaced_entries
|
|
872
|
+
)
|
|
873
|
+
replaced_entries[rels_path] = updated_rels_xml if updated_rels_xml
|
|
874
|
+
end
|
|
875
|
+
|
|
478
876
|
replaced_entries[entry_name] = updated_sheet_xml
|
|
479
877
|
end
|
|
480
878
|
|
|
@@ -546,59 +944,445 @@ module Xlsxrb
|
|
|
546
944
|
class << self
|
|
547
945
|
private
|
|
548
946
|
|
|
549
|
-
#: (String name, String? sheet_xml, Array[String] shared_strings, untyped
|
|
550
|
-
def build_worksheet(name, sheet_xml, shared_strings,
|
|
551
|
-
|
|
947
|
+
#: (String name, String? sheet_xml, Array[String] shared_strings, untyped styles, ?state: Symbol, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?hyperlinks: Hash[String, 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, ?merged_cells: Array[String]?, ?auto_filter: String?, ?tables: Array[Hash[Symbol, untyped]]?, ?shapes: Array[Hash[Symbol, untyped]]?, ?freeze_pane: Hash[Symbol, untyped]?, ?page_setup: Hash[Symbol, untyped]?, ?page_margins: Hash[Symbol, untyped]?, ?header_footer: Hash[Symbol, untyped]?, ?print_options: Hash[Symbol, untyped]?, ?sheet_protection: Hash[Symbol, untyped]?, ?sheet_view: Hash[Symbol, untyped]?, ?sheet_properties: Hash[Symbol, untyped]?, ?sheet_format: Hash[Symbol, untyped]?, ?row_breaks: Array[untyped]?, ?col_breaks: Array[untyped]?, ?protected_ranges: Array[Hash[Symbol, untyped]]?, ?cell_watches: Array[String]?) -> Elements::Worksheet
|
|
948
|
+
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, merged_cells: nil, auto_filter: nil, tables: nil, shapes: nil, freeze_pane: nil, page_setup: nil, page_margins: nil, header_footer: nil, print_options: nil, sheet_protection: nil, sheet_view: nil, sheet_properties: nil, sheet_format: nil, row_breaks: nil, col_breaks: nil, protected_ranges: nil, cell_watches: nil)
|
|
949
|
+
sheet_dimension = dimension || sheet_xml&.slice(/<(?:[a-zA-Z0-9_]+:)?dimension\b[^>]*\bref=["']([^"']+)["']/, 1)
|
|
950
|
+
if sheet_xml.nil? || sheet_xml.empty?
|
|
951
|
+
return Elements::Worksheet.new(
|
|
952
|
+
name: name, state: state, styles: styles, images: images || [], date1904: date1904,
|
|
953
|
+
dimension: sheet_dimension, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells,
|
|
954
|
+
merged_cells: merged_cells || [], auto_filter: auto_filter,
|
|
955
|
+
tables: tables || [], shapes: shapes || [], freeze_pane: freeze_pane,
|
|
956
|
+
page_setup: page_setup || {}, page_margins: page_margins, header_footer: header_footer || {},
|
|
957
|
+
print_options: print_options || {}, sheet_protection: sheet_protection, sheet_view: sheet_view || {},
|
|
958
|
+
sheet_properties: sheet_properties || {}, sheet_format: sheet_format || {}, row_breaks: row_breaks || [], col_breaks: col_breaks || [],
|
|
959
|
+
protected_ranges: protected_ranges || [], cell_watches: cell_watches || []
|
|
960
|
+
)
|
|
961
|
+
end
|
|
552
962
|
|
|
553
|
-
|
|
963
|
+
sheet_hyperlinks = hyperlinks || resolve_hyperlinks(sheet_xml, zip_reader: zip_reader, entry_name: entry_name)
|
|
964
|
+
sheet_comments = comments || resolve_comments(zip_reader: zip_reader, entry_name: entry_name)
|
|
965
|
+
sheet_images = images || resolve_images(zip_reader: zip_reader, entry_name: entry_name)
|
|
966
|
+
raw_rows = Ooxml::WorksheetParser.parse(sheet_xml, shared_strings: shared_strings, styles: styles)
|
|
554
967
|
raw_columns = Ooxml::WorksheetParser.parse_columns(sheet_xml)
|
|
555
968
|
|
|
556
|
-
rows = raw_rows.map { |rr| build_row_from_raw(rr) }
|
|
969
|
+
rows = raw_rows.map { |rr| build_row_from_raw(rr, sheet_hyperlinks, sheet_comments, styles, date1904: date1904) }
|
|
557
970
|
columns = raw_columns.map do |rc|
|
|
558
971
|
# Columns from OOXML are 1-based min/max ranges; convert to 0-based
|
|
972
|
+
col_unmapped = {}
|
|
973
|
+
col_unmapped[:style_index] = rc[:style_index] if rc[:style_index]
|
|
559
974
|
Elements::Column.new(
|
|
560
975
|
index: (rc[:min] || 1) - 1,
|
|
561
976
|
width: rc[:width],
|
|
562
977
|
hidden: rc[:hidden] || false,
|
|
563
978
|
custom_width: rc[:custom_width] || false,
|
|
564
|
-
outline_level: rc[:outline_level]
|
|
979
|
+
outline_level: rc[:outline_level],
|
|
980
|
+
style_index: rc[:style_index],
|
|
981
|
+
unmapped_data: col_unmapped
|
|
982
|
+
)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
cfs = if sheet_xml.include?("<conditionalFormatting")
|
|
986
|
+
listener = Ooxml::Reader::ConditionalFormattingListener.new
|
|
987
|
+
Ooxml::XmlParser.parse(sheet_xml, listener)
|
|
988
|
+
listener.rules
|
|
989
|
+
else
|
|
990
|
+
[]
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
dvs = if sheet_xml.include?("<dataValidation")
|
|
994
|
+
listener = Ooxml::Reader::DataValidationsListener.new
|
|
995
|
+
Ooxml::XmlParser.parse(sheet_xml, listener)
|
|
996
|
+
listener.validations
|
|
997
|
+
else
|
|
998
|
+
[]
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
sheet_merged_cells = merged_cells || if sheet_xml.include?("<mergeCell")
|
|
1002
|
+
listener = Ooxml::Reader::MergeCellsListener.new
|
|
1003
|
+
Ooxml::XmlParser.parse(sheet_xml, listener)
|
|
1004
|
+
listener.ranges
|
|
1005
|
+
else
|
|
1006
|
+
[]
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
sheet_auto_filter = auto_filter || if sheet_xml.include?("<autoFilter")
|
|
1010
|
+
listener = Ooxml::Reader::AutoFilterListener.new
|
|
1011
|
+
Ooxml::XmlParser.parse(sheet_xml, listener)
|
|
1012
|
+
listener.ref
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
sheet_tables = tables || resolve_tables(zip_reader: zip_reader, entry_name: entry_name)
|
|
1016
|
+
sheet_shapes = shapes || resolve_shapes(zip_reader: zip_reader, entry_name: entry_name)
|
|
1017
|
+
|
|
1018
|
+
sheet_view_data = if freeze_pane.nil? || sheet_view.nil?
|
|
1019
|
+
if sheet_xml.include?("<sheetView")
|
|
1020
|
+
v_listener = Ooxml::Reader::SheetViewListener.new
|
|
1021
|
+
Ooxml::XmlParser.parse(sheet_xml, v_listener)
|
|
1022
|
+
{ view: v_listener.view, pane: v_listener.pane }
|
|
1023
|
+
else
|
|
1024
|
+
{ view: {}, pane: nil }
|
|
1025
|
+
end
|
|
1026
|
+
else
|
|
1027
|
+
{ view: sheet_view, pane: freeze_pane }
|
|
1028
|
+
end
|
|
1029
|
+
sheet_freeze_pane = freeze_pane || sheet_view_data[:pane]
|
|
1030
|
+
sheet_view_val = sheet_view || sheet_view_data[:view] || {}
|
|
1031
|
+
|
|
1032
|
+
sheet_prot = sheet_protection || if sheet_xml.include?("<sheetProtection")
|
|
1033
|
+
prot_listener = Ooxml::Reader::SheetProtectionListener.new
|
|
1034
|
+
Ooxml::XmlParser.parse(sheet_xml, prot_listener)
|
|
1035
|
+
prot_listener.protection
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
sheet_props = sheet_properties || if sheet_xml.include?("<sheetPr")
|
|
1039
|
+
pr_listener = Ooxml::Reader::SheetPropertiesListener.new
|
|
1040
|
+
Ooxml::XmlParser.parse(sheet_xml, pr_listener)
|
|
1041
|
+
pr_listener.properties
|
|
1042
|
+
else
|
|
1043
|
+
{}
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
sheet_fmt = sheet_format || if sheet_xml.include?("<sheetFormatPr")
|
|
1047
|
+
fmt_listener = Ooxml::Reader::SheetFormatListener.new
|
|
1048
|
+
Ooxml::XmlParser.parse(sheet_xml, fmt_listener)
|
|
1049
|
+
fmt_listener.properties
|
|
1050
|
+
else
|
|
1051
|
+
{}
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
print_data = if page_setup.nil? || page_margins.nil? || header_footer.nil? || print_options.nil? || row_breaks.nil? || col_breaks.nil?
|
|
1055
|
+
if sheet_xml.match?(/<(?:pageSetup|pageMargins|headerFooter|printOptions|rowBreaks|colBreaks)\b/)
|
|
1056
|
+
pg_listener = Ooxml::Reader::PrintPageListener.new
|
|
1057
|
+
Ooxml::XmlParser.parse(sheet_xml, pg_listener)
|
|
1058
|
+
{
|
|
1059
|
+
page_margins: pg_listener.page_margins,
|
|
1060
|
+
page_setup: pg_listener.page_setup,
|
|
1061
|
+
header_footer: pg_listener.header_footer,
|
|
1062
|
+
print_options: pg_listener.print_options,
|
|
1063
|
+
row_breaks: pg_listener.row_breaks,
|
|
1064
|
+
col_breaks: pg_listener.col_breaks
|
|
1065
|
+
}
|
|
1066
|
+
else
|
|
1067
|
+
{ page_margins: nil, page_setup: {}, header_footer: {}, print_options: {}, row_breaks: [], col_breaks: [] }
|
|
1068
|
+
end
|
|
1069
|
+
else
|
|
1070
|
+
{ page_margins: page_margins, page_setup: page_setup, header_footer: header_footer, print_options: print_options, row_breaks: row_breaks, col_breaks: col_breaks }
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
sheet_prot_ranges = protected_ranges || if sheet_xml.include?("<protectedRanges")
|
|
1074
|
+
pr_listener = Ooxml::Reader::ProtectedRangesListener.new
|
|
1075
|
+
Ooxml::XmlParser.parse(sheet_xml, pr_listener)
|
|
1076
|
+
pr_listener.ranges
|
|
1077
|
+
else
|
|
1078
|
+
[]
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
sheet_watches = cell_watches || if sheet_xml.include?("<cellWatches")
|
|
1082
|
+
cw_listener = Ooxml::Reader::CellWatchesListener.new
|
|
1083
|
+
Ooxml::XmlParser.parse(sheet_xml, cw_listener)
|
|
1084
|
+
cw_listener.watches
|
|
1085
|
+
else
|
|
1086
|
+
[]
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
Elements::Worksheet.new(
|
|
1090
|
+
name: name,
|
|
1091
|
+
rows: rows,
|
|
1092
|
+
columns: columns,
|
|
1093
|
+
conditional_formatting: cfs,
|
|
1094
|
+
data_validations: dvs,
|
|
1095
|
+
state: state,
|
|
1096
|
+
hyperlinks: sheet_hyperlinks,
|
|
1097
|
+
comments: sheet_comments,
|
|
1098
|
+
images: sheet_images,
|
|
1099
|
+
styles: styles,
|
|
1100
|
+
date1904: date1904,
|
|
1101
|
+
dimension: sheet_dimension,
|
|
1102
|
+
trim_empty_rows: trim_empty_rows,
|
|
1103
|
+
pad_empty_rows: pad_empty_rows,
|
|
1104
|
+
pad_empty_cells: pad_empty_cells,
|
|
1105
|
+
merged_cells: sheet_merged_cells,
|
|
1106
|
+
auto_filter: sheet_auto_filter,
|
|
1107
|
+
tables: sheet_tables,
|
|
1108
|
+
shapes: sheet_shapes,
|
|
1109
|
+
freeze_pane: sheet_freeze_pane,
|
|
1110
|
+
page_setup: page_setup || print_data[:page_setup] || {},
|
|
1111
|
+
page_margins: page_margins || print_data[:page_margins],
|
|
1112
|
+
header_footer: header_footer || print_data[:header_footer] || {},
|
|
1113
|
+
print_options: print_options || print_data[:print_options] || {},
|
|
1114
|
+
sheet_protection: sheet_prot,
|
|
1115
|
+
sheet_view: sheet_view_val,
|
|
1116
|
+
sheet_properties: sheet_props,
|
|
1117
|
+
sheet_format: sheet_fmt,
|
|
1118
|
+
row_breaks: row_breaks || print_data[:row_breaks] || [],
|
|
1119
|
+
col_breaks: col_breaks || print_data[:col_breaks] || [],
|
|
1120
|
+
protected_ranges: sheet_prot_ranges,
|
|
1121
|
+
cell_watches: sheet_watches
|
|
1122
|
+
)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
#: (String? sheet_xml, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?rels_xml: String?) -> Hash[String, Hash[Symbol, untyped]]
|
|
1126
|
+
def resolve_hyperlinks(sheet_xml, zip_reader: nil, entry_name: nil, rels_xml: nil)
|
|
1127
|
+
return {} if sheet_xml.nil? || !sheet_xml.include?("hyperlink")
|
|
1128
|
+
|
|
1129
|
+
links = Ooxml::WorksheetParser.parse_hyperlinks(sheet_xml)
|
|
1130
|
+
return {} if links.empty?
|
|
1131
|
+
|
|
1132
|
+
rid_to_url = {}
|
|
1133
|
+
if rels_xml && !rels_xml.empty?
|
|
1134
|
+
rid_to_url = Ooxml::RelationshipsParser.parse(rels_xml)
|
|
1135
|
+
elsif zip_reader && entry_name
|
|
1136
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
1137
|
+
rels_content = zip_reader.read_entry(rels_path)
|
|
1138
|
+
rid_to_url = Ooxml::RelationshipsParser.parse(rels_content) if rels_content && !rels_content.empty?
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
result = {}
|
|
1142
|
+
links.each do |link|
|
|
1143
|
+
entry = {}
|
|
1144
|
+
if link[:rid]
|
|
1145
|
+
url = rid_to_url[link[:rid]]
|
|
1146
|
+
entry[:url] = url if url
|
|
1147
|
+
end
|
|
1148
|
+
entry[:display] = link[:display] if link[:display]
|
|
1149
|
+
entry[:tooltip] = link[:tooltip] if link[:tooltip]
|
|
1150
|
+
entry[:location] = link[:location] if link[:location]
|
|
1151
|
+
result[link[:ref]] = entry unless entry.empty?
|
|
1152
|
+
end
|
|
1153
|
+
result
|
|
1154
|
+
end
|
|
1155
|
+
|
|
1156
|
+
#: (String base_dir, String target) -> String
|
|
1157
|
+
def resolve_relative_path(base_dir, target)
|
|
1158
|
+
if target.start_with?("/")
|
|
1159
|
+
target.delete_prefix("/")
|
|
1160
|
+
elsif target.start_with?("..")
|
|
1161
|
+
parts = base_dir.split("/") + target.split("/")
|
|
1162
|
+
resolved = []
|
|
1163
|
+
parts.each { |p| p == ".." ? resolved.pop : resolved << p }
|
|
1164
|
+
resolved.join("/")
|
|
1165
|
+
else
|
|
1166
|
+
"#{base_dir}/#{target}"
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
#: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
|
|
1171
|
+
def resolve_comments(zip_reader: nil, entry_name: nil)
|
|
1172
|
+
return [] unless zip_reader && entry_name
|
|
1173
|
+
|
|
1174
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
1175
|
+
rels_xml = zip_reader.read_entry(rels_path)
|
|
1176
|
+
return [] if rels_xml.nil? || rels_xml.empty?
|
|
1177
|
+
|
|
1178
|
+
parser = REXML::Parsers::SAX2Parser.new(rels_xml)
|
|
1179
|
+
listener = Ooxml::Reader::RelsListener.new
|
|
1180
|
+
parser.listen(listener)
|
|
1181
|
+
parser.parse
|
|
1182
|
+
|
|
1183
|
+
rel = listener.relationships.find { |r| r[:type]&.end_with?("/comments") }
|
|
1184
|
+
return [] unless rel
|
|
1185
|
+
|
|
1186
|
+
base_dir = File.dirname(entry_name)
|
|
1187
|
+
comments_path = resolve_relative_path(base_dir, rel[:target])
|
|
1188
|
+
|
|
1189
|
+
xml = zip_reader.read_entry(comments_path)
|
|
1190
|
+
return [] if xml.nil? || xml.empty?
|
|
1191
|
+
|
|
1192
|
+
c_parser = REXML::Parsers::SAX2Parser.new(xml)
|
|
1193
|
+
c_listener = Ooxml::Reader::CommentsListener.new
|
|
1194
|
+
c_parser.listen(c_listener)
|
|
1195
|
+
c_parser.parse
|
|
1196
|
+
c_listener.comments
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
#: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Elements::Image]
|
|
1200
|
+
def resolve_images(zip_reader: nil, entry_name: nil)
|
|
1201
|
+
return [] unless zip_reader && entry_name
|
|
1202
|
+
|
|
1203
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
1204
|
+
rels_xml = zip_reader.read_entry(rels_path)
|
|
1205
|
+
return [] if rels_xml.nil? || rels_xml.empty?
|
|
1206
|
+
|
|
1207
|
+
parser = REXML::Parsers::SAX2Parser.new(rels_xml)
|
|
1208
|
+
listener = Ooxml::Reader::RelsListener.new
|
|
1209
|
+
parser.listen(listener)
|
|
1210
|
+
parser.parse
|
|
1211
|
+
|
|
1212
|
+
drawing_rel = listener.relationships.find { |r| r[:type]&.end_with?("/drawing") }
|
|
1213
|
+
return [] unless drawing_rel
|
|
1214
|
+
|
|
1215
|
+
base_dir = File.dirname(entry_name)
|
|
1216
|
+
target = drawing_rel[:target]
|
|
1217
|
+
drawing_path = resolve_relative_path(base_dir, target)
|
|
1218
|
+
drawing_xml = zip_reader.read_entry(drawing_path)
|
|
1219
|
+
return [] if drawing_xml.nil? || drawing_xml.empty?
|
|
1220
|
+
|
|
1221
|
+
drawing_rels_path = drawing_path.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
1222
|
+
drawing_rels_xml = zip_reader.read_entry(drawing_rels_path)
|
|
1223
|
+
drawing_rels = {}
|
|
1224
|
+
if drawing_rels_xml && !drawing_rels_xml.empty?
|
|
1225
|
+
d_parser = REXML::Parsers::SAX2Parser.new(drawing_rels_xml)
|
|
1226
|
+
d_listener = Ooxml::Reader::RelsListener.new
|
|
1227
|
+
d_parser.listen(d_listener)
|
|
1228
|
+
d_parser.parse
|
|
1229
|
+
d_listener.relationships.each do |r|
|
|
1230
|
+
drawing_rels[r[:id]] = r[:target]
|
|
1231
|
+
end
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
p_parser = REXML::Parsers::SAX2Parser.new(drawing_xml)
|
|
1235
|
+
img_listener = Ooxml::Reader::DrawingImagesListener.new
|
|
1236
|
+
p_parser.listen(img_listener)
|
|
1237
|
+
p_parser.parse
|
|
1238
|
+
|
|
1239
|
+
drawing_dir = File.dirname(drawing_path)
|
|
1240
|
+
img_listener.images.map do |raw_img|
|
|
1241
|
+
rid = raw_img[:embed_rid]
|
|
1242
|
+
media_target = drawing_rels[rid]
|
|
1243
|
+
img_data = nil
|
|
1244
|
+
filename = nil
|
|
1245
|
+
if media_target
|
|
1246
|
+
media_path = resolve_relative_path(drawing_dir, media_target)
|
|
1247
|
+
filename = File.basename(media_path)
|
|
1248
|
+
img_data = zip_reader.read_entry(media_path)
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
from_col = raw_img[:from_col] || 0
|
|
1252
|
+
from_row = raw_img[:from_row] || 0
|
|
1253
|
+
to_col = raw_img[:to_col]
|
|
1254
|
+
to_row = raw_img[:to_row]
|
|
1255
|
+
cx = raw_img[:cx]
|
|
1256
|
+
cy = raw_img[:cy]
|
|
1257
|
+
name = raw_img[:name]
|
|
1258
|
+
|
|
1259
|
+
Elements::Image.new(
|
|
1260
|
+
filename: filename || "image.png",
|
|
1261
|
+
name: name,
|
|
1262
|
+
data: img_data,
|
|
1263
|
+
from_col: from_col,
|
|
1264
|
+
from_row: from_row,
|
|
1265
|
+
to_col: to_col,
|
|
1266
|
+
to_row: to_row,
|
|
1267
|
+
cx: cx,
|
|
1268
|
+
cy: cy,
|
|
1269
|
+
unmapped_data: raw_img
|
|
565
1270
|
)
|
|
566
1271
|
end
|
|
1272
|
+
end
|
|
567
1273
|
|
|
568
|
-
|
|
1274
|
+
#: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
|
|
1275
|
+
def resolve_tables(zip_reader: nil, entry_name: nil)
|
|
1276
|
+
return [] unless zip_reader && entry_name
|
|
1277
|
+
|
|
1278
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
1279
|
+
rels_xml = zip_reader.read_entry(rels_path)
|
|
1280
|
+
return [] if rels_xml.nil? || rels_xml.empty?
|
|
1281
|
+
|
|
1282
|
+
parser = REXML::Parsers::SAX2Parser.new(rels_xml)
|
|
1283
|
+
listener = Ooxml::Reader::RelsListener.new
|
|
1284
|
+
parser.listen(listener)
|
|
1285
|
+
parser.parse
|
|
1286
|
+
|
|
1287
|
+
table_rels = listener.relationships.select { |r| r[:type]&.end_with?("/table") }
|
|
1288
|
+
return [] if table_rels.empty?
|
|
1289
|
+
|
|
1290
|
+
base_dir = File.dirname(entry_name)
|
|
1291
|
+
table_rels.filter_map do |rel|
|
|
1292
|
+
tbl_path = resolve_relative_path(base_dir, rel[:target])
|
|
1293
|
+
tbl_xml = zip_reader.read_entry(tbl_path)
|
|
1294
|
+
next if tbl_xml.nil? || tbl_xml.empty?
|
|
1295
|
+
|
|
1296
|
+
t_parser = REXML::Parsers::SAX2Parser.new(tbl_xml)
|
|
1297
|
+
t_listener = Ooxml::Reader::TableListener.new
|
|
1298
|
+
t_parser.listen(t_listener)
|
|
1299
|
+
t_parser.parse
|
|
1300
|
+
t_listener.table
|
|
1301
|
+
end
|
|
569
1302
|
end
|
|
570
1303
|
|
|
571
|
-
#: (
|
|
572
|
-
def
|
|
1304
|
+
#: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
|
|
1305
|
+
def resolve_shapes(zip_reader: nil, entry_name: nil)
|
|
1306
|
+
return [] unless zip_reader && entry_name
|
|
1307
|
+
|
|
1308
|
+
rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
|
|
1309
|
+
rels_xml = zip_reader.read_entry(rels_path)
|
|
1310
|
+
return [] if rels_xml.nil? || rels_xml.empty?
|
|
1311
|
+
|
|
1312
|
+
parser = REXML::Parsers::SAX2Parser.new(rels_xml)
|
|
1313
|
+
listener = Ooxml::Reader::RelsListener.new
|
|
1314
|
+
parser.listen(listener)
|
|
1315
|
+
parser.parse
|
|
1316
|
+
|
|
1317
|
+
drawing_rel = listener.relationships.find { |r| r[:type]&.end_with?("/drawing") }
|
|
1318
|
+
return [] unless drawing_rel
|
|
1319
|
+
|
|
1320
|
+
base_dir = File.dirname(entry_name)
|
|
1321
|
+
drawing_path = resolve_relative_path(base_dir, drawing_rel[:target])
|
|
1322
|
+
drawing_xml = zip_reader.read_entry(drawing_path)
|
|
1323
|
+
return [] if drawing_xml.nil? || drawing_xml.empty?
|
|
1324
|
+
|
|
1325
|
+
d_parser = REXML::Parsers::SAX2Parser.new(drawing_xml)
|
|
1326
|
+
d_listener = Ooxml::Reader::DrawingShapesListener.new
|
|
1327
|
+
d_parser.listen(d_listener)
|
|
1328
|
+
d_parser.parse
|
|
1329
|
+
d_listener.shapes
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
#: (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)
|
|
1333
|
+
def build_row_from_raw(raw_row, hyperlinks = {}, comments = [], styles = nil, date1904: false)
|
|
573
1334
|
return raw_row if raw_row.is_a?(Elements::Row)
|
|
574
1335
|
|
|
1336
|
+
comments_map = if comments.is_a?(Hash)
|
|
1337
|
+
comments
|
|
1338
|
+
else
|
|
1339
|
+
comments.each_with_object({}) { |c, acc| (r = c[:ref] || c[:cell]) && (acc[r.to_s.upcase] = c) }
|
|
1340
|
+
end
|
|
1341
|
+
|
|
575
1342
|
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
|
-
|
|
1343
|
+
cells = raw_cells.map do |rc|
|
|
1344
|
+
if rc.is_a?(Elements::Cell)
|
|
1345
|
+
hl = hyperlinks && !hyperlinks.empty? ? hyperlinks[rc.ref] : nil
|
|
1346
|
+
cm = comments_map && !comments_map.empty? ? comments_map[rc.ref] : nil
|
|
1347
|
+
fmt = rc.format_code || (NumberFormatter.format_code_for(rc.style_index, styles) if styles && rc.style_index)
|
|
1348
|
+
if hl || cm || (fmt && rc.format_code != fmt) || (date1904 && !rc.date1904?)
|
|
1349
|
+
rc.with(hyperlink: hl || rc.hyperlink, comment: cm || rc.comment, format_code: fmt || rc.format_code, date1904: date1904 || rc.date1904?)
|
|
1350
|
+
else
|
|
1351
|
+
rc
|
|
1352
|
+
end
|
|
1353
|
+
else
|
|
1354
|
+
parsed = Elements::Cell.parse_ref(rc[:ref]) if rc[:ref]
|
|
1355
|
+
row_idx = parsed ? parsed[0] : raw_row[:index]
|
|
1356
|
+
col_idx = parsed ? parsed[1] : 0
|
|
1357
|
+
ref = rc[:ref] || "#{Elements::Cell.column_letter(col_idx)}#{row_idx + 1}"
|
|
1358
|
+
hl = hyperlinks ? hyperlinks[ref] : nil
|
|
1359
|
+
cm = comments_map ? comments_map[ref] : nil
|
|
1360
|
+
fmt = rc[:format_code] || (NumberFormatter.format_code_for(rc[:style_index], styles) if styles && rc[:style_index])
|
|
1361
|
+
|
|
1362
|
+
val = rc[:value]
|
|
1363
|
+
raw_val = rc[:raw_value] || val&.to_s
|
|
1364
|
+
cell_errors = Elements::Cell.validate(row_idx, col_idx, val)
|
|
1365
|
+
if !cell_errors.empty? && rc[:source]
|
|
1366
|
+
cell_errors = cell_errors.map do |err|
|
|
1367
|
+
"#{err} (at #{rc[:source][:part]} row #{rc[:source][:row] + 1} cell #{rc[:ref] || "unknown"})"
|
|
1368
|
+
end
|
|
1369
|
+
end
|
|
1370
|
+
|
|
1371
|
+
Elements::Cell.new(
|
|
1372
|
+
row_index: row_idx,
|
|
1373
|
+
column_index: col_idx,
|
|
1374
|
+
value: val,
|
|
1375
|
+
formula: rc[:formula],
|
|
1376
|
+
style_index: rc[:style_index],
|
|
1377
|
+
hyperlink: hl,
|
|
1378
|
+
comment: cm,
|
|
1379
|
+
raw_value: raw_val,
|
|
1380
|
+
format_code: fmt,
|
|
1381
|
+
errors: cell_errors,
|
|
1382
|
+
date1904: date1904
|
|
1383
|
+
)
|
|
1384
|
+
end
|
|
1385
|
+
end
|
|
602
1386
|
attrs = raw_row[:attrs] || {}
|
|
603
1387
|
row_errors = Elements::Row.validate(raw_row[:index], cells)
|
|
604
1388
|
if !row_errors.empty? && raw_row[:source]
|
|
@@ -609,19 +1393,23 @@ module Xlsxrb
|
|
|
609
1393
|
# simplecov:enable
|
|
610
1394
|
end
|
|
611
1395
|
end
|
|
1396
|
+
row_unmapped = {}
|
|
1397
|
+
row_unmapped[:style_index] = attrs[:style_index] if attrs[:style_index]
|
|
612
1398
|
Elements::Row.new(
|
|
613
1399
|
index: raw_row[:index],
|
|
614
1400
|
cells: cells,
|
|
615
1401
|
height: attrs[:height],
|
|
616
1402
|
hidden: attrs[:hidden] || false,
|
|
617
1403
|
custom_height: attrs[:custom_height] || false,
|
|
1404
|
+
collapsed: attrs[:collapsed] || false,
|
|
618
1405
|
outline_level: attrs[:outline_level],
|
|
1406
|
+
unmapped_data: row_unmapped,
|
|
619
1407
|
errors: row_errors
|
|
620
1408
|
)
|
|
621
1409
|
end
|
|
622
1410
|
|
|
623
|
-
#: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index) -> Hash[Symbol, untyped]
|
|
624
|
-
def build_raw_cell(cell, sst, sst_index)
|
|
1411
|
+
#: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
|
|
1412
|
+
def build_raw_cell(cell, sst, sst_index, date1904: false)
|
|
625
1413
|
# simplecov:disable
|
|
626
1414
|
# Edge case / untested delegation block
|
|
627
1415
|
ref = cell.ref
|
|
@@ -645,9 +1433,9 @@ module Xlsxrb
|
|
|
645
1433
|
result[:value] = value.code
|
|
646
1434
|
result[:type] = "e"
|
|
647
1435
|
when Date
|
|
648
|
-
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
|
|
1436
|
+
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
|
|
649
1437
|
when Time
|
|
650
|
-
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
|
|
1438
|
+
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
|
|
651
1439
|
# simplecov:enable
|
|
652
1440
|
when NilClass
|
|
653
1441
|
# empty cell
|
|
@@ -687,6 +1475,7 @@ module Xlsxrb
|
|
|
687
1475
|
attrs[:height] = row.height if row.height
|
|
688
1476
|
attrs[:hidden] = true if row.hidden
|
|
689
1477
|
attrs[:custom_height] = true if row.custom_height
|
|
1478
|
+
attrs[:collapsed] = true if row.collapsed
|
|
690
1479
|
attrs[:outline_level] = row.outline_level if row.outline_level
|
|
691
1480
|
attrs
|
|
692
1481
|
# simplecov:enable
|
|
@@ -695,12 +1484,13 @@ module Xlsxrb
|
|
|
695
1484
|
#: (untyped source, String? password) -> untyped
|
|
696
1485
|
def prepare_source_io(source, password)
|
|
697
1486
|
if source.is_a?(String)
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1487
|
+
bin_source = source.encoding == Encoding::BINARY ? source : source.b
|
|
1488
|
+
if bin_source.start_with?("PK\x03\x04".b) || bin_source.include?("\x00".b) || Ooxml::Cfb::Reader.cfb?(bin_source)
|
|
1489
|
+
if Ooxml::Cfb::Reader.cfb?(bin_source)
|
|
1490
|
+
decrypted_zip = Ooxml::Crypto.decrypt(bin_source, password)
|
|
701
1491
|
StringIO.new(decrypted_zip)
|
|
702
1492
|
else
|
|
703
|
-
StringIO.new(
|
|
1493
|
+
StringIO.new(bin_source)
|
|
704
1494
|
end
|
|
705
1495
|
elsif File.file?(source)
|
|
706
1496
|
first_bytes = begin
|
|
@@ -823,20 +1613,27 @@ module Xlsxrb
|
|
|
823
1613
|
|
|
824
1614
|
#: (String sheet_xml, String new_sheet_data_xml) -> String
|
|
825
1615
|
def replace_sheet_data(sheet_xml, new_sheet_data_xml)
|
|
826
|
-
|
|
1616
|
+
sd_term = sheet_xml.index("sheetData")
|
|
1617
|
+
return sheet_xml unless sd_term
|
|
1618
|
+
|
|
1619
|
+
sheet_data_start = sheet_xml.rindex("<", sd_term)
|
|
827
1620
|
return sheet_xml unless sheet_data_start
|
|
828
1621
|
|
|
829
|
-
|
|
1622
|
+
prefix_len = sd_term - (sheet_data_start + 1)
|
|
1623
|
+
tag_prefix = sheet_xml[sheet_data_start + 1, prefix_len]
|
|
1624
|
+
|
|
1625
|
+
open_tag_end = sheet_xml.index(">", sd_term)
|
|
830
1626
|
return sheet_xml unless open_tag_end
|
|
831
1627
|
|
|
832
1628
|
prefix = sheet_xml[0...sheet_data_start]
|
|
833
1629
|
suffix = if sheet_xml.getbyte(open_tag_end - 1) == 47
|
|
834
1630
|
sheet_xml[(open_tag_end + 1)..]
|
|
835
1631
|
else
|
|
836
|
-
|
|
1632
|
+
close_tag = "</#{tag_prefix}sheetData>"
|
|
1633
|
+
close_tag_start = sheet_xml.index(close_tag, open_tag_end)
|
|
837
1634
|
return sheet_xml unless close_tag_start
|
|
838
1635
|
|
|
839
|
-
sheet_xml[(close_tag_start +
|
|
1636
|
+
sheet_xml[(close_tag_start + close_tag.bytesize)..]
|
|
840
1637
|
end
|
|
841
1638
|
|
|
842
1639
|
"#{prefix}#{new_sheet_data_xml}#{suffix}"
|
|
@@ -861,11 +1658,204 @@ module Xlsxrb
|
|
|
861
1658
|
sheet_xml.sub(/<dimension\b([^>]*)\bref="[^"]*"/, "<dimension\\1ref=\"#{dim_ref}\"")
|
|
862
1659
|
end
|
|
863
1660
|
|
|
864
|
-
#: (
|
|
865
|
-
def
|
|
1661
|
+
#: (String sheet_xml, String? orig_rels_xml, Hash[String, untyped] new_hyperlinks) -> [String, String?]
|
|
1662
|
+
def update_sheet_hyperlinks(sheet_xml, orig_rels_xml, new_hyperlinks)
|
|
1663
|
+
existing_rels = []
|
|
1664
|
+
max_rid_num = 0
|
|
1665
|
+
if orig_rels_xml && !orig_rels_xml.empty?
|
|
1666
|
+
orig_rels_xml.scan(%r{<Relationship\b([^>]*)/?>}) do |m|
|
|
1667
|
+
attrs = m[0]
|
|
1668
|
+
id = attrs[/Id="([^"]*)"/, 1]
|
|
1669
|
+
type = attrs[/Type="([^"]*)"/, 1]
|
|
1670
|
+
target = attrs[/Target="([^"]*)"/, 1]
|
|
1671
|
+
target_mode = attrs[/TargetMode="([^"]*)"/, 1]
|
|
1672
|
+
if id =~ /\ArId(\d+)\z/
|
|
1673
|
+
num = Regexp.last_match(1).to_i
|
|
1674
|
+
max_rid_num = num if num > max_rid_num
|
|
1675
|
+
end
|
|
1676
|
+
existing_rels << { id: id, type: type, target: target, target_mode: target_mode } unless type == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
|
1677
|
+
end
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
sheet_rels = existing_rels.dup
|
|
1681
|
+
cur_rid = max_rid_num
|
|
1682
|
+
hl_elements = []
|
|
1683
|
+
|
|
1684
|
+
new_hyperlinks.each do |cell_ref, link|
|
|
1685
|
+
entry = link.is_a?(Hash) ? link.dup : { url: link.to_s }
|
|
1686
|
+
url = entry[:url]
|
|
1687
|
+
loc = entry[:location]
|
|
1688
|
+
if url&.start_with?("#")
|
|
1689
|
+
loc ||= url.delete_prefix("#")
|
|
1690
|
+
url = nil
|
|
1691
|
+
end
|
|
1692
|
+
display = entry[:display]
|
|
1693
|
+
tooltip = entry[:tooltip]
|
|
1694
|
+
|
|
1695
|
+
attrs = if url && !url.empty?
|
|
1696
|
+
cur_rid += 1
|
|
1697
|
+
rid = "rId#{cur_rid}"
|
|
1698
|
+
sheet_rels << {
|
|
1699
|
+
id: rid,
|
|
1700
|
+
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
|
|
1701
|
+
target: url,
|
|
1702
|
+
target_mode: "External"
|
|
1703
|
+
}
|
|
1704
|
+
%(ref="#{cell_ref}" r:id="#{rid}")
|
|
1705
|
+
elsif loc && !loc.empty?
|
|
1706
|
+
%(ref="#{cell_ref}" location="#{Ooxml::XmlBuilder.escape(loc)}")
|
|
1707
|
+
else
|
|
1708
|
+
next
|
|
1709
|
+
end
|
|
1710
|
+
|
|
1711
|
+
attrs << %( display="#{Ooxml::XmlBuilder.escape(display)}") if display
|
|
1712
|
+
attrs << %( tooltip="#{Ooxml::XmlBuilder.escape(tooltip)}") if tooltip
|
|
1713
|
+
hl_elements << "<hyperlink #{attrs}/>"
|
|
1714
|
+
end
|
|
1715
|
+
|
|
1716
|
+
updated_sheet_xml = sheet_xml.dup
|
|
1717
|
+
updated_sheet_xml.sub!(/<worksheet\b/, '<worksheet xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"') if hl_elements.any? && !updated_sheet_xml.include?('xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"')
|
|
1718
|
+
|
|
1719
|
+
new_hyperlinks_xml = hl_elements.empty? ? "" : "<hyperlinks>#{hl_elements.join}</hyperlinks>"
|
|
1720
|
+
|
|
1721
|
+
if updated_sheet_xml.include?("<hyperlinks")
|
|
1722
|
+
updated_sheet_xml.sub!(%r{<hyperlinks\b[^>]*/\s*>|<hyperlinks\b[^>]*>.*?</hyperlinks>}m, new_hyperlinks_xml)
|
|
1723
|
+
elsif hl_elements.any?
|
|
1724
|
+
insert_pattern = %r{<(?:printOptions|pageMargins|pageSetup|headerFooter|rowBreaks|colBreaks|customProperties|cellWatches|ignoredErrors|smartTags|drawing|legacyDrawing|legacyDrawingHF|picture|oleObjects|controls|webPublishItems|tableParts|extLst)\b|</worksheet>}
|
|
1725
|
+
match = updated_sheet_xml.match(insert_pattern)
|
|
1726
|
+
if match
|
|
1727
|
+
updated_sheet_xml.insert(match.begin(0), new_hyperlinks_xml)
|
|
1728
|
+
else
|
|
1729
|
+
updated_sheet_xml << new_hyperlinks_xml
|
|
1730
|
+
end
|
|
1731
|
+
end
|
|
1732
|
+
|
|
1733
|
+
updated_rels_xml = if sheet_rels.empty? && (orig_rels_xml.nil? || orig_rels_xml.empty?)
|
|
1734
|
+
nil
|
|
1735
|
+
else
|
|
1736
|
+
io = StringIO.new
|
|
1737
|
+
b = Ooxml::XmlBuilder.new(io)
|
|
1738
|
+
b.declaration
|
|
1739
|
+
b.open_tag("Relationships", { xmlns: "http://schemas.openxmlformats.org/package/2006/relationships" })
|
|
1740
|
+
sheet_rels.each do |rel|
|
|
1741
|
+
rel_attrs = { Id: rel[:id], Type: rel[:type], Target: rel[:target] }
|
|
1742
|
+
rel_attrs[:TargetMode] = rel[:target_mode] if rel[:target_mode]
|
|
1743
|
+
b.empty_tag("Relationship", rel_attrs)
|
|
1744
|
+
end
|
|
1745
|
+
b.close_tag("Relationships")
|
|
1746
|
+
io.string
|
|
1747
|
+
end
|
|
1748
|
+
|
|
1749
|
+
[updated_sheet_xml, updated_rels_xml]
|
|
1750
|
+
end
|
|
1751
|
+
|
|
1752
|
+
#: (String sheet_xml, String _rels_path, String? orig_rels_xml, Array[Hash[Symbol, untyped]] new_comments, Ooxml::ZipReader zip_reader, Integer sheet_idx, String entry_name, Hash[String, String] replaced_entries) -> [String, String?]
|
|
1753
|
+
def update_sheet_comments(sheet_xml, _rels_path, orig_rels_xml, new_comments, zip_reader, sheet_idx, entry_name, replaced_entries)
|
|
1754
|
+
existing_rels = []
|
|
1755
|
+
max_rid_num = 0
|
|
1756
|
+
if orig_rels_xml && !orig_rels_xml.empty?
|
|
1757
|
+
orig_rels_xml.scan(%r{<Relationship\b([^>]*)/?>}) do |m|
|
|
1758
|
+
attrs = m[0]
|
|
1759
|
+
id = attrs[/Id="([^"]*)"/, 1]
|
|
1760
|
+
type = attrs[/Type="([^"]*)"/, 1]
|
|
1761
|
+
target = attrs[/Target="([^"]*)"/, 1]
|
|
1762
|
+
target_mode = attrs[/TargetMode="([^"]*)"/, 1]
|
|
1763
|
+
if id =~ /\ArId(\d+)\z/
|
|
1764
|
+
num = Regexp.last_match(1).to_i
|
|
1765
|
+
max_rid_num = num if num > max_rid_num
|
|
1766
|
+
end
|
|
1767
|
+
existing_rels << { id: id, type: type, target: target, target_mode: target_mode }
|
|
1768
|
+
end
|
|
1769
|
+
end
|
|
1770
|
+
|
|
1771
|
+
comments_rel = existing_rels.find { |r| r[:type]&.end_with?("/comments") }
|
|
1772
|
+
vml_rel = existing_rels.find { |r| r[:type]&.end_with?("/vmlDrawing") }
|
|
1773
|
+
|
|
1774
|
+
writer = Ooxml::Writer.new
|
|
1775
|
+
new_comments.each do |c|
|
|
1776
|
+
writer.add_comment(c[:cell] || c[:ref], c[:text], author: c[:author] || "Author")
|
|
1777
|
+
end
|
|
1778
|
+
comments_xml = writer.send(:generate_comments_xml, writer.comments)
|
|
1779
|
+
vml_xml = writer.send(:generate_vml_drawing_xml, writer.comments)
|
|
1780
|
+
|
|
1781
|
+
updated_sheet_xml = sheet_xml.dup
|
|
1782
|
+
|
|
1783
|
+
if comments_rel
|
|
1784
|
+
base_dir = File.dirname(entry_name)
|
|
1785
|
+
comments_path = resolve_relative_path(base_dir, comments_rel[:target])
|
|
1786
|
+
replaced_entries[comments_path] = comments_xml
|
|
1787
|
+
if vml_rel
|
|
1788
|
+
vml_path = resolve_relative_path(base_dir, vml_rel[:target])
|
|
1789
|
+
replaced_entries[vml_path] = vml_xml
|
|
1790
|
+
end
|
|
1791
|
+
elsif new_comments.any?
|
|
1792
|
+
existing_comment_count = zip_reader.entry_names.count { |n| n =~ %r{\Axl/comments\d+\.xml\z} }
|
|
1793
|
+
comment_file_num = existing_comment_count + sheet_idx + 1
|
|
1794
|
+
comments_path = "xl/comments#{comment_file_num}.xml"
|
|
1795
|
+
vml_path = "xl/drawings/vmlDrawing#{comment_file_num}.vml"
|
|
1796
|
+
replaced_entries[comments_path] = comments_xml
|
|
1797
|
+
replaced_entries[vml_path] = vml_xml
|
|
1798
|
+
|
|
1799
|
+
max_rid_num += 1
|
|
1800
|
+
c_rid = "rId#{max_rid_num}"
|
|
1801
|
+
existing_rels << {
|
|
1802
|
+
id: c_rid,
|
|
1803
|
+
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
|
|
1804
|
+
target: "../comments#{comment_file_num}.xml"
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
max_rid_num += 1
|
|
1808
|
+
vml_rid = "rId#{max_rid_num}"
|
|
1809
|
+
existing_rels << {
|
|
1810
|
+
id: vml_rid,
|
|
1811
|
+
type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing",
|
|
1812
|
+
target: "../drawings/vmlDrawing#{comment_file_num}.vml"
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
unless updated_sheet_xml.include?("<legacyDrawing")
|
|
1816
|
+
legacy_tag = %(<legacyDrawing r:id="#{vml_rid}"/>)
|
|
1817
|
+
insert_pattern = %r{<(?:picture|oleObjects|controls|webPublishItems|tableParts|extLst)\b|</worksheet>}
|
|
1818
|
+
match = updated_sheet_xml.match(insert_pattern)
|
|
1819
|
+
if match
|
|
1820
|
+
updated_sheet_xml.insert(match.begin(0), legacy_tag)
|
|
1821
|
+
else
|
|
1822
|
+
updated_sheet_xml << legacy_tag
|
|
1823
|
+
end
|
|
1824
|
+
end
|
|
1825
|
+
|
|
1826
|
+
updated_sheet_xml.sub!(/<worksheet\b/, '<worksheet xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"') unless updated_sheet_xml.include?('xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"')
|
|
1827
|
+
|
|
1828
|
+
ct_xml = replaced_entries["[Content_Types].xml"] || zip_reader.read_entry("[Content_Types].xml")
|
|
1829
|
+
if ct_xml && !ct_xml.include?("comments#{comment_file_num}.xml")
|
|
1830
|
+
override_tag = %(<Override PartName="/#{comments_path}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>)
|
|
1831
|
+
ct_xml = ct_xml.sub(%r{</Types>}, "#{override_tag}</Types>")
|
|
1832
|
+
unless ct_xml.include?('Extension="vml"')
|
|
1833
|
+
default_vml = %(<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>)
|
|
1834
|
+
ct_xml = ct_xml.sub(%r{</Types>}, "#{default_vml}</Types>")
|
|
1835
|
+
end
|
|
1836
|
+
replaced_entries["[Content_Types].xml"] = ct_xml
|
|
1837
|
+
end
|
|
1838
|
+
end
|
|
1839
|
+
|
|
1840
|
+
io = StringIO.new
|
|
1841
|
+
b = Ooxml::XmlBuilder.new(io)
|
|
1842
|
+
b.declaration
|
|
1843
|
+
b.open_tag("Relationships", { xmlns: "http://schemas.openxmlformats.org/package/2006/relationships" })
|
|
1844
|
+
existing_rels.each do |rel|
|
|
1845
|
+
rel_attrs = { Id: rel[:id], Type: rel[:type], Target: rel[:target] }
|
|
1846
|
+
rel_attrs[:TargetMode] = rel[:target_mode] if rel[:target_mode]
|
|
1847
|
+
b.empty_tag("Relationship", rel_attrs)
|
|
1848
|
+
end
|
|
1849
|
+
b.close_tag("Relationships")
|
|
1850
|
+
|
|
1851
|
+
[updated_sheet_xml, io.string]
|
|
1852
|
+
end
|
|
1853
|
+
|
|
1854
|
+
#: (Array[Elements::Row] rows, Array[untyped] shared_strings, Hash[untyped, Integer] sst_index, ?use_sst: bool, ?date1904: bool) -> String
|
|
1855
|
+
def serialize_sheet_data_xml(rows, shared_strings, sst_index, use_sst: true, date1904: false)
|
|
866
1856
|
io = StringIO.new
|
|
867
1857
|
io.write("<sheetData>")
|
|
868
|
-
ws = Ooxml::WorksheetWriter.new(io)
|
|
1858
|
+
ws = Ooxml::WorksheetWriter.new(io, date1904: date1904)
|
|
869
1859
|
ws.instance_variable_set(:@started, true)
|
|
870
1860
|
|
|
871
1861
|
rows.each do |row|
|
|
@@ -915,8 +1905,8 @@ module Xlsxrb
|
|
|
915
1905
|
# @param sst_index [Hash{String => Integer}] Shared strings index mapping.
|
|
916
1906
|
# @return [Hash{Symbol => Object}] Raw cell hash.
|
|
917
1907
|
# @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)
|
|
1908
|
+
#: (Integer row_index, Integer col_index, untyped value, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
|
|
1909
|
+
def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index, date1904: false)
|
|
920
1910
|
# simplecov:disable
|
|
921
1911
|
# Edge case / untested delegation block
|
|
922
1912
|
ref = "#{Elements::Cell.column_letter(col_index)}#{row_index + 1}"
|
|
@@ -943,9 +1933,9 @@ module Xlsxrb
|
|
|
943
1933
|
result[:value] = value.code
|
|
944
1934
|
result[:type] = "e"
|
|
945
1935
|
when Date
|
|
946
|
-
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
|
|
1936
|
+
result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
|
|
947
1937
|
when Time
|
|
948
|
-
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
|
|
1938
|
+
result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
|
|
949
1939
|
# simplecov:enable
|
|
950
1940
|
when NilClass
|
|
951
1941
|
# empty cell
|