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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/README.md +2 -0
  4. data/lib/xlsxrb/colors.rb +118 -0
  5. data/lib/xlsxrb/elements/cell.rb +205 -16
  6. data/lib/xlsxrb/elements/column.rb +6 -4
  7. data/lib/xlsxrb/elements/coordinate_access.rb +32 -0
  8. data/lib/xlsxrb/elements/image.rb +128 -0
  9. data/lib/xlsxrb/elements/row.rb +206 -9
  10. data/lib/xlsxrb/elements/styles.rb +157 -0
  11. data/lib/xlsxrb/elements/types.rb +95 -1
  12. data/lib/xlsxrb/elements/workbook.rb +85 -4
  13. data/lib/xlsxrb/elements/worksheet.rb +424 -29
  14. data/lib/xlsxrb/elements.rb +2 -0
  15. data/lib/xlsxrb/number_formatter.rb +429 -0
  16. data/lib/xlsxrb/ooxml/cfb.rb +1 -1
  17. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +7 -5
  18. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +11 -4
  19. data/lib/xlsxrb/ooxml/reader.rb +12 -14
  20. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +7 -1
  21. data/lib/xlsxrb/ooxml/styles_parser.rb +39 -9
  22. data/lib/xlsxrb/ooxml/utils.rb +34 -18
  23. data/lib/xlsxrb/ooxml/workbook_parser.rb +53 -15
  24. data/lib/xlsxrb/ooxml/workbook_writer.rb +65 -14
  25. data/lib/xlsxrb/ooxml/worksheet_parser.rb +319 -79
  26. data/lib/xlsxrb/ooxml/worksheet_writer.rb +206 -61
  27. data/lib/xlsxrb/ooxml/writer/features_xml.rb +4 -2
  28. data/lib/xlsxrb/ooxml/writer.rb +14 -0
  29. data/lib/xlsxrb/ooxml/zip_reader.rb +109 -25
  30. data/lib/xlsxrb/stream_row.rb +243 -16
  31. data/lib/xlsxrb/stream_sheet.rb +414 -15
  32. data/lib/xlsxrb/stream_writer.rb +213 -17
  33. data/lib/xlsxrb/style_builder.rb +308 -22
  34. data/lib/xlsxrb/utils.rb +158 -0
  35. data/lib/xlsxrb/version.rb +1 -1
  36. data/lib/xlsxrb/workbook_builder.rb +73 -3
  37. data/lib/xlsxrb/worksheet_builder.rb +227 -16
  38. data/lib/xlsxrb.rb +578 -79
  39. data/sig/generated/xlsxrb/colors.rbs +40 -0
  40. data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
  41. data/sig/generated/xlsxrb/elements/column.rbs +3 -2
  42. data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
  43. data/sig/generated/xlsxrb/elements/image.rbs +82 -0
  44. data/sig/generated/xlsxrb/elements/row.rbs +83 -4
  45. data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
  46. data/sig/generated/xlsxrb/elements/types.rbs +43 -0
  47. data/sig/generated/xlsxrb/elements/workbook.rbs +51 -2
  48. data/sig/generated/xlsxrb/elements/worksheet.rbs +231 -18
  49. data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
  50. data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
  51. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -0
  52. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
  53. data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
  54. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
  55. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +28 -6
  56. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +3 -1
  57. data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
  58. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +10 -0
  59. data/sig/generated/xlsxrb/stream_row.rbs +100 -6
  60. data/sig/generated/xlsxrb/stream_sheet.rbs +247 -13
  61. data/sig/generated/xlsxrb/stream_writer.rbs +120 -14
  62. data/sig/generated/xlsxrb/style_builder.rbs +55 -18
  63. data/sig/generated/xlsxrb/utils.rbs +119 -0
  64. data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
  65. data/sig/generated/xlsxrb/worksheet_builder.rbs +107 -11
  66. data/sig/generated/xlsxrb.rbs +186 -27
  67. metadata +11 -1
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Xlsxrb
6
+ module Elements
7
+ # Represents an embedded image anchored to worksheet cells.
8
+ # Coordinates (from_row, from_col, to_row, to_col) are 0-based.
9
+ #
10
+ # @example Access image properties
11
+ # image = sheet.images.first
12
+ # image.filename #=> "image1.png"
13
+ # image.cell_ref #=> "B3"
14
+ # image.content_type #=> "image/png"
15
+ # image.data #=> "\x89PNG..."
16
+ #
17
+ # @api public
18
+ class Image < Data.define(:name, :filename, :content_type, :data, :from_col, :from_row, :to_col, :to_row, :cx, :cy, :unmapped_data)
19
+ MIME_TYPES = {
20
+ "png" => "image/png",
21
+ "jpg" => "image/jpeg",
22
+ "jpeg" => "image/jpeg",
23
+ "gif" => "image/gif",
24
+ "svg" => "image/svg+xml",
25
+ "tif" => "image/tiff",
26
+ "tiff" => "image/tiff",
27
+ "bmp" => "image/bmp",
28
+ "webp" => "image/webp",
29
+ "emf" => "image/x-emf",
30
+ "wmf" => "image/x-wmf"
31
+ }.freeze
32
+
33
+ # @param filename [String] Target filename (e.g. "image1.png").
34
+ # @param name [String, nil] Image or shape name.
35
+ # @param content_type [String, nil] MIME content type (e.g. "image/png").
36
+ # @param data [String, nil] Raw binary image data.
37
+ # @param from_col [Integer] 0-based starting column index.
38
+ # @param from_row [Integer] 0-based starting row index.
39
+ # @param to_col [Integer, nil] 0-based ending column index (nil for 1-cell anchor).
40
+ # @param to_row [Integer, nil] 0-based ending row index (nil for 1-cell anchor).
41
+ # @param cx [Integer, nil] Extent width in EMUs (English Metric Units).
42
+ # @param cy [Integer, nil] Extent height in EMUs (English Metric Units).
43
+ # @param unmapped_data [Hash] Additional metadata from drawing XML.
44
+ #: (filename: String, ?name: String?, ?content_type: String?, ?data: String?, ?from_col: Integer, ?from_row: Integer, ?to_col: Integer?, ?to_row: Integer?, ?cx: Integer?, ?cy: Integer?, ?unmapped_data: Hash[untyped, untyped]) -> void
45
+ # rubocop:disable Naming/MethodParameterName
46
+ def initialize(filename:, name: nil, content_type: nil, data: nil, from_col: 0, from_row: 0,
47
+ to_col: nil, to_row: nil, cx: nil, cy: nil, unmapped_data: EMPTY_HASH)
48
+ # rubocop:enable Naming/MethodParameterName
49
+ ext = File.extname(filename).delete_prefix(".").downcase
50
+ mime = content_type || MIME_TYPES[ext] || "application/octet-stream"
51
+ super(
52
+ name: name,
53
+ filename: filename,
54
+ content_type: mime,
55
+ data: data&.b,
56
+ from_col: from_col,
57
+ from_row: from_row,
58
+ to_col: to_col,
59
+ to_row: to_row,
60
+ cx: cx,
61
+ cy: cy,
62
+ unmapped_data: unmapped_data
63
+ )
64
+ end
65
+
66
+ # Returns the top-left cell coordinate reference string (e.g. "A1").
67
+ #
68
+ # @return [String]
69
+ # @api public
70
+ #: () -> String
71
+ def cell_ref
72
+ "#{Cell.column_letter(from_col)}#{from_row + 1}"
73
+ end
74
+ alias cell cell_ref
75
+ alias ref cell_ref
76
+
77
+ # Returns the bottom-right cell coordinate reference string (e.g. "B5"), or nil for 1-cell anchors.
78
+ #
79
+ # @return [String, nil]
80
+ # @api public
81
+ #: () -> String?
82
+ def to_cell_ref
83
+ return nil if to_col.nil? || to_row.nil?
84
+
85
+ "#{Cell.column_letter(to_col)}#{to_row + 1}"
86
+ end
87
+
88
+ # Returns whether this image uses a one-cell anchor.
89
+ #
90
+ # @return [Boolean]
91
+ # @api public
92
+ #: () -> bool
93
+ def one_cell_anchor?
94
+ to_col.nil? || to_row.nil?
95
+ end
96
+
97
+ # Returns whether this image uses a two-cell anchor.
98
+ #
99
+ # @return [Boolean]
100
+ # @api public
101
+ #: () -> bool
102
+ def two_cell_anchor?
103
+ !one_cell_anchor?
104
+ end
105
+
106
+ # Returns the size of the binary image data in bytes.
107
+ #
108
+ # @return [Integer]
109
+ # @api public
110
+ #: () -> Integer
111
+ def bytesize
112
+ data ? data.bytesize : 0
113
+ end
114
+
115
+ # Writes the image data directly to a file on disk.
116
+ #
117
+ # @param path [String] Target file path.
118
+ # @return [Integer] Number of bytes written.
119
+ # @api public
120
+ #: (String path) -> Integer
121
+ def write_to(path)
122
+ raise Error, "No image binary data available to write" unless data
123
+
124
+ File.binwrite(path, data)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -14,7 +14,7 @@ module Xlsxrb
14
14
  #
15
15
  # @api public
16
16
  # rubocop:disable Style/DataInheritance -- Required as class syntax for mutant subject matcher
17
- class Row < Data.define(:index, :cells, :height, :hidden, :custom_height, :outline_level, :unmapped_data, :errors)
17
+ class Row < Data.define(:index, :cells, :height, :hidden, :custom_height, :collapsed, :outline_level, :unmapped_data, :errors)
18
18
  # rubocop:enable Style/DataInheritance
19
19
  [Enumerable].each { |m| include m }
20
20
 
@@ -23,17 +23,18 @@ module Xlsxrb
23
23
  # @param height [Float, Integer, nil] Row height in points.
24
24
  # @param hidden [Boolean] Whether the row is hidden.
25
25
  # @param custom_height [Boolean] Whether custom height is set.
26
+ # @param collapsed [Boolean] Whether the row is collapsed.
26
27
  # @param outline_level [Integer, nil] Grouping/outline level.
27
28
  # @param unmapped_data [Hash] Additional metadata.
28
29
  # @param errors [Array<String>, nil] Validation errors.
29
- #: (index: Integer, ?cells: Array[Elements::Cell], ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil, ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
30
- def initialize(index:, cells: EMPTY_CELLS, height: nil, hidden: false, custom_height: false, outline_level: nil,
31
- unmapped_data: EMPTY_HASH, errors: nil)
30
+ #: (index: Integer, ?cells: Array[Elements::Cell], ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?collapsed: bool, ?outline_level: Integer | nil, ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
31
+ def initialize(index:, cells: EMPTY_CELLS, height: nil, hidden: false, custom_height: false, collapsed: false,
32
+ outline_level: nil, unmapped_data: EMPTY_HASH, errors: nil)
32
33
  computed_errors = errors || self.class.validate(index, cells)
33
34
  computed_errors = computed_errors.freeze unless computed_errors.frozen?
34
35
  cells = cells.freeze unless cells.frozen?
35
36
  super(index: index, cells: cells, height: height, hidden: hidden,
36
- custom_height: custom_height, outline_level: outline_level,
37
+ custom_height: custom_height, collapsed: collapsed, outline_level: outline_level,
37
38
  unmapped_data: unmapped_data, errors: computed_errors)
38
39
  end
39
40
 
@@ -53,18 +54,144 @@ module Xlsxrb
53
54
  when Symbol
54
55
  case col_index
55
56
  when :cells then cells
57
+ when :values then values
58
+ when :raw_values then raw_values
59
+ when :formatted_values then formatted_values
60
+ when :formulas then formulas
56
61
  when :index then index
57
62
  when :height then height
58
63
  when :hidden then hidden
59
64
  when :custom_height then custom_height
65
+ when :collapsed then collapsed
60
66
  when :outline_level then outline_level
61
- when :attrs then { height: height, hidden: hidden, custom_height: custom_height, outline_level: outline_level }
67
+ when :style_index then style_index
68
+ when :hidden? then hidden?
69
+ when :collapsed? then collapsed?
70
+ when :custom_height? then custom_height?
71
+ when :attributes then attributes
72
+ when :cells_hash then cells_hash
73
+ when :attrs
74
+ h = { height: height, hidden: hidden, custom_height: custom_height, collapsed: collapsed, outline_level: outline_level }
75
+ h[:style_index] = style_index if style_index
76
+ h
62
77
  end
63
78
  else
64
79
  cells[col_index]
65
80
  end
66
81
  end
67
82
 
83
+ # Returns a new Row with missing cells padded up to the maximum column index.
84
+ #
85
+ # @return [Elements::Row]
86
+ # @api public
87
+ #: () -> Elements::Row
88
+ def pad_empty_cells
89
+ return self if cells.empty?
90
+
91
+ max_col = cells.map(&:column_index).max || 0
92
+ return self if cells.size == max_col + 1
93
+
94
+ is_d1904 = cells.first ? cells.first.date1904? : false
95
+ cell_map = {}
96
+ cells.each { |c| cell_map[c.column_index] = c }
97
+ padded = Array.new(max_col + 1)
98
+ (0..max_col).each do |c_idx|
99
+ padded[c_idx] = cell_map[c_idx] || Cell.fast_create(index, c_idx, nil, nil, nil, nil, nil, is_d1904)
100
+ end
101
+ Row.new(
102
+ index: index,
103
+ cells: padded,
104
+ height: height,
105
+ hidden: hidden,
106
+ custom_height: custom_height,
107
+ collapsed: collapsed,
108
+ outline_level: outline_level,
109
+ unmapped_data: unmapped_data,
110
+ errors: errors
111
+ )
112
+ end
113
+
114
+ # Returns a coordinate-keyed Hash of cell values (e.g. {"A1" => "value", "B1" => 123}).
115
+ #
116
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
117
+ # @return [Hash{String => Object}]
118
+ # @api public
119
+ #: (?type_cast: bool) -> Hash[String, untyped]
120
+ def cells_hash(type_cast: false)
121
+ result = {}
122
+ cells.each do |c|
123
+ val = c.value
124
+ if type_cast && val.is_a?(Numeric) && c.format_code
125
+ fmt_type = NumberFormatter.format_type(c.format_code)
126
+ begin
127
+ case fmt_type
128
+ when :date
129
+ val = Ooxml::Utils.serial_to_date(val, date1904: c.date1904?)
130
+ when :datetime, :time
131
+ val = Ooxml::Utils.serial_to_datetime(val, date1904: c.date1904?)
132
+ end
133
+ rescue StandardError
134
+ # Keep val numeric on failure
135
+ end
136
+ end
137
+ result[c.ref] = val
138
+ end
139
+ result
140
+ end
141
+
142
+ # Returns whether the row is hidden.
143
+ #
144
+ # @return [Boolean]
145
+ # @api public
146
+ #: () -> bool
147
+ def hidden?
148
+ hidden == true
149
+ end
150
+
151
+ # Returns whether the row is collapsed.
152
+ #
153
+ # @return [Boolean]
154
+ # @api public
155
+ #: () -> bool
156
+ def collapsed?
157
+ collapsed == true
158
+ end
159
+
160
+ # Returns whether custom height is set.
161
+ #
162
+ # @return [Boolean]
163
+ # @api public
164
+ #: () -> bool
165
+ def custom_height?
166
+ custom_height == true
167
+ end
168
+
169
+ # Returns a frozen Hash of row attributes.
170
+ #
171
+ # @return [Hash{Symbol => Object}]
172
+ # @api public
173
+ #: () -> Hash[Symbol, untyped]
174
+ def attributes
175
+ h = {
176
+ index: index,
177
+ height: height,
178
+ hidden: hidden,
179
+ custom_height: custom_height,
180
+ collapsed: collapsed,
181
+ outline_level: outline_level
182
+ }
183
+ h[:style_index] = style_index if style_index
184
+ h.freeze
185
+ end
186
+
187
+ # Returns style_index if present in unmapped_data.
188
+ #
189
+ # @return [Integer, nil]
190
+ #: () -> Integer?
191
+ def style_index
192
+ unmapped_data[:style_index]
193
+ end
194
+
68
195
  # Iterate over cells in this row.
69
196
  #
70
197
  # @example
@@ -130,6 +257,17 @@ module Xlsxrb
130
257
  errors.empty?
131
258
  end
132
259
 
260
+ # Returns whether the row contains no cell values (or all cell values are nil or empty strings).
261
+ #
262
+ # @return [Boolean]
263
+ # @api public
264
+ #: () -> bool
265
+ def empty?
266
+ return true if cells.empty?
267
+
268
+ cells.all? { |c| c.value.nil? || (c.value.is_a?(String) && c.value.empty?) }
269
+ end
270
+
133
271
  # Returns the cell at the given 0-based column index, or nil.
134
272
  #
135
273
  # @param column_index [Integer] 0-based column index.
@@ -142,15 +280,74 @@ module Xlsxrb
142
280
 
143
281
  # Returns cell values as an Array (sparse columns get nil).
144
282
  #
283
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
145
284
  # @return [Array<Object>]
146
285
  # @api public
147
- #: () -> Array[untyped]
148
- def values
286
+ #: (?type_cast: bool) -> Array[untyped]
287
+ def values(type_cast: false)
288
+ return [] if cells.empty?
289
+
290
+ max_col = cells.max_by(&:column_index)&.column_index || 0
291
+ result = Array.new(max_col + 1)
292
+ cells.each do |c|
293
+ val = c.value
294
+ if type_cast && val.is_a?(Numeric) && c.format_code
295
+ fmt_type = NumberFormatter.format_type(c.format_code)
296
+ begin
297
+ case fmt_type
298
+ when :date
299
+ val = Ooxml::Utils.serial_to_date(val, date1904: c.date1904?)
300
+ when :datetime, :time
301
+ val = Ooxml::Utils.serial_to_datetime(val, date1904: c.date1904?)
302
+ end
303
+ rescue StandardError
304
+ # Keep val numeric on failure
305
+ end
306
+ end
307
+ result[c.column_index] = val
308
+ end
309
+ result
310
+ end
311
+
312
+ # Returns unparsed raw cell values as an Array (sparse columns get nil).
313
+ #
314
+ # @return [Array<String, nil>]
315
+ # @api public
316
+ #: () -> Array[String?]
317
+ def raw_values
318
+ return [] if cells.empty?
319
+
320
+ max_col = cells.max_by(&:column_index).column_index
321
+ result = Array.new(max_col + 1)
322
+ cells.each { |c| result[c.column_index] = c.raw_value }
323
+ result
324
+ end
325
+
326
+ # Returns formatted string representations of cell values as an Array (sparse columns get nil).
327
+ #
328
+ # @return [Array<String, nil>]
329
+ # @api public
330
+ #: () -> Array[String?]
331
+ def formatted_values
332
+ return [] if cells.empty?
333
+
334
+ max_col = cells.max_by(&:column_index).column_index
335
+ result = Array.new(max_col + 1)
336
+ cells.each { |c| result[c.column_index] = c.formatted_value }
337
+ result
338
+ end
339
+
340
+ # Returns formula expressions without leading '=' as an Array (sparse columns get nil).
341
+ #
342
+ # @return [Array<String, nil>]
343
+ # @api public
344
+ #: () -> Array[String?]
345
+ def formulas
149
346
  return [] if cells.empty?
150
347
 
151
348
  max_col = cells.max_by(&:column_index).column_index
152
349
  result = Array.new(max_col + 1)
153
- cells.each { |c| result[c.column_index] = c.value }
350
+ cells.each { |c| result[c.column_index] = c.formula_expression }
154
351
  result
155
352
  end
156
353
 
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "../number_formatter"
6
+ require_relative "../ooxml/utils"
7
+
8
+ module Xlsxrb
9
+ module Elements
10
+ # Represents the workbook stylesheet definitions (fonts, fills, borders, cellXfs, and numFmts).
11
+ # Inherits from Hash for backward compatibility with existing styles hash structures,
12
+ # while providing precomputed O(1) number format classifications.
13
+ #
14
+ # @api public
15
+ class Styles < Hash #[untyped, untyped]
16
+ # Initializes the styles container, optionally wrapping an existing styles hash.
17
+ #
18
+ # @param initial_hash [Hash, nil] Initial styles dictionary.
19
+ #: (?Hash[untyped, untyped]? initial_hash) -> void
20
+ def initialize(initial_hash = nil)
21
+ super()
22
+ merge!(initial_hash) if initial_hash.is_a?(Hash)
23
+ @format_codes = nil
24
+ @format_types = nil
25
+ @date_flags = nil
26
+ @datetime_flags = nil
27
+ @time_flags = nil
28
+ end
29
+
30
+ # Returns the format code string for a given style index.
31
+ #
32
+ # @param style_index [Integer, nil]
33
+ # @return [String, nil]
34
+ # @api public
35
+ #: (Integer? style_index) -> String?
36
+ def number_format(style_index)
37
+ return nil unless style_index.is_a?(Integer) && style_index >= 0
38
+
39
+ precompute! unless @format_codes
40
+ @format_codes[style_index]
41
+ end
42
+ alias format_code number_format
43
+ alias format_code_for number_format
44
+
45
+ # Returns the classified format category (:date, :datetime, :time, :number, :text, :general) for a style index.
46
+ #
47
+ # @param style_index [Integer, nil]
48
+ # @return [Symbol, nil]
49
+ # @api public
50
+ #: (Integer? style_index) -> Symbol?
51
+ def format_type(style_index)
52
+ return nil unless style_index.is_a?(Integer) && style_index >= 0
53
+
54
+ precompute! unless @format_types
55
+ @format_types[style_index]
56
+ end
57
+ alias classification_for format_type
58
+
59
+ # Returns whether the style index represents a date, datetime, or time pattern.
60
+ #
61
+ # @param style_index [Integer, nil]
62
+ # @return [Boolean]
63
+ # @api public
64
+ #: (Integer? style_index) -> bool
65
+ def date_format?(style_index)
66
+ return false unless style_index.is_a?(Integer) && style_index >= 0
67
+
68
+ precompute! unless @date_flags
69
+ @date_flags[style_index] || false
70
+ end
71
+
72
+ # Returns whether the style index represents a datetime pattern.
73
+ #
74
+ # @param style_index [Integer, nil]
75
+ # @return [Boolean]
76
+ # @api public
77
+ #: (Integer? style_index) -> bool
78
+ def datetime_format?(style_index)
79
+ return false unless style_index.is_a?(Integer) && style_index >= 0
80
+
81
+ precompute! unless @datetime_flags
82
+ @datetime_flags[style_index] || false
83
+ end
84
+
85
+ # Returns whether the style index represents a time pattern.
86
+ #
87
+ # @param style_index [Integer, nil]
88
+ # @return [Boolean]
89
+ # @api public
90
+ #: (Integer? style_index) -> bool
91
+ def time_format?(style_index)
92
+ return false unless style_index.is_a?(Integer) && style_index >= 0
93
+
94
+ precompute! unless @time_flags
95
+ @time_flags[style_index] || false
96
+ end
97
+
98
+ # Returns whether the style index represents a date-only pattern.
99
+ #
100
+ # @param style_index [Integer, nil]
101
+ # @return [Boolean]
102
+ # @api public
103
+ #: (Integer? style_index) -> bool
104
+ def date_only_format?(style_index)
105
+ format_type(style_index) == :date
106
+ end
107
+
108
+ # Precomputes format code and type classification lookups for all cellXfs.
109
+ #
110
+ # @return [self]
111
+ # @api public
112
+ #: () -> self
113
+ def precompute!
114
+ cell_xfs = self[:cell_xfs]
115
+ size = cell_xfs.is_a?(Array) ? cell_xfs.size : 0
116
+
117
+ codes = Array.new(size)
118
+ types = Array.new(size)
119
+ d_flags = Array.new(size, false)
120
+ dt_flags = Array.new(size, false)
121
+ t_flags = Array.new(size, false)
122
+
123
+ if size.positive?
124
+ cell_style_xfs = self[:cell_style_xfs]
125
+ num_fmts = self[:num_fmts] || {}
126
+
127
+ size.times do |i|
128
+ xf = cell_xfs[i]
129
+ next unless xf.is_a?(Hash)
130
+
131
+ num_fmt_id = xf[:num_fmt_id]
132
+ if (num_fmt_id.nil? || num_fmt_id.zero?) && xf[:xf_id] && cell_style_xfs.is_a?(Array)
133
+ parent_xf = cell_style_xfs[xf[:xf_id]]
134
+ num_fmt_id = parent_xf[:num_fmt_id] if parent_xf.is_a?(Hash)
135
+ end
136
+
137
+ code = num_fmt_id ? (num_fmts[num_fmt_id] || Ooxml::Utils::BUILTIN_NUM_FMT_CODES[num_fmt_id]) : nil
138
+
139
+ codes[i] = code
140
+ type = NumberFormatter.format_type(code)
141
+ types[i] = type
142
+ d_flags[i] = %i[date datetime time].include?(type)
143
+ dt_flags[i] = (type == :datetime)
144
+ t_flags[i] = (type == :time)
145
+ end
146
+ end
147
+
148
+ @format_codes = codes
149
+ @format_types = types
150
+ @date_flags = d_flags
151
+ @datetime_flags = dt_flags
152
+ @time_flags = t_flags
153
+ self
154
+ end
155
+ end
156
+ end
157
+ end
@@ -53,6 +53,43 @@ module Xlsxrb
53
53
  end
54
54
  end
55
55
 
56
+ # Represents a formatted text run within a rich text string.
57
+ #
58
+ # @example
59
+ # run = Elements::RichTextRun.new(text: "Hello", font: { bold: true })
60
+ #
61
+ # @api public
62
+ RichTextRun = Data.define(:text, :font) do
63
+ # @param text [String] Plain text for this run.
64
+ # @param font [Hash, nil] Optional font styling properties.
65
+ #: (?text: String, ?font: Hash[Symbol, untyped]?) -> void
66
+ def initialize(text: "", font: nil)
67
+ super
68
+ end
69
+
70
+ # Supports hash-like key access for backwards compatibility.
71
+ #
72
+ # @param key [Symbol]
73
+ # @return [Object, nil]
74
+ # @api public
75
+ #: (Symbol key) -> untyped
76
+ def [](key)
77
+ case key
78
+ when :text then text
79
+ when :font then font
80
+ end
81
+ end
82
+
83
+ # Returns the plain text of this run.
84
+ #
85
+ # @return [String]
86
+ # @api public
87
+ #: () -> String
88
+ def to_s
89
+ text
90
+ end
91
+ end
92
+
56
93
  # Represents a rich text string with multiple formatting runs.
57
94
  #
58
95
  # @example
@@ -66,7 +103,64 @@ module Xlsxrb
66
103
  # @api public
67
104
  #: () -> String
68
105
  def to_s
69
- runs.map { |r| r[:text] }.join
106
+ runs.map { |r| r[:text] || r.to_s }.join
107
+ end
108
+ end
109
+
110
+ # Represents a worksheet hyperlink with an optional target URL, display text, tooltip (ScreenTip), and internal location.
111
+ #
112
+ # @example Create a hyperlink
113
+ # link = Elements::Hyperlink.new(url: "https://example.com", tooltip: "Visit website")
114
+ #
115
+ # @api public
116
+ # rubocop:disable Lint/DataDefineOverride -- :display represents the hyperlink display text attribute in OOXML
117
+ Hyperlink = Data.define(:url, :display, :tooltip, :location) do
118
+ # rubocop:enable Lint/DataDefineOverride
119
+ # @param url [String, nil] Target external URL or URI.
120
+ # @param display [String, nil] Optional friendly display label.
121
+ # @param tooltip [String, nil] Optional hover ScreenTip text.
122
+ # @param location [String, nil] Optional internal sheet/cell reference.
123
+ #: (?url: String?, ?display: String?, ?tooltip: String?, ?location: String?) -> void
124
+ def initialize(url: nil, display: nil, tooltip: nil, location: nil)
125
+ super
126
+ end
127
+
128
+ # Supports hash-like key access for backwards compatibility.
129
+ #
130
+ # @param key [Symbol]
131
+ # @return [Object, nil]
132
+ # @api public
133
+ #: (Symbol key) -> untyped
134
+ def [](key)
135
+ case key
136
+ when :url then url
137
+ when :display then display
138
+ when :tooltip then tooltip
139
+ when :location then location
140
+ end
141
+ end
142
+
143
+ # Supports Hash conversion.
144
+ #
145
+ # @return [Hash{Symbol => String}]
146
+ # @api public
147
+ #: () -> Hash[Symbol, String]
148
+ def to_h
149
+ h = {}
150
+ h[:url] = url if url
151
+ h[:display] = display if display
152
+ h[:tooltip] = tooltip if tooltip
153
+ h[:location] = location if location
154
+ h
155
+ end
156
+
157
+ # Returns the target URL or location string.
158
+ #
159
+ # @return [String, nil]
160
+ # @api public
161
+ #: () -> String?
162
+ def to_s
163
+ url || location
70
164
  end
71
165
  end
72
166
  end