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
@@ -20,6 +20,281 @@ module Xlsxrb
20
20
  grey: "FF808080"
21
21
  }.freeze
22
22
 
23
+ CSS_COLORS = {
24
+ aliceblue: "F0F8FF",
25
+ antiquewhite: "FAEBD7",
26
+ aqua: "00FFFF",
27
+ aquamarine: "7FFFD4",
28
+ azure: "F0FFFF",
29
+ beige: "F5F5DC",
30
+ bisque: "FFE4C4",
31
+ black: "000000",
32
+ blanchedalmond: "FFEBCD",
33
+ blue: "0000FF",
34
+ blueviolet: "8A2BE2",
35
+ brown: "A52A2A",
36
+ burlywood: "DEB887",
37
+ cadetblue: "5F9EA0",
38
+ chartreuse: "7FFF00",
39
+ chocolate: "D2691E",
40
+ coral: "FF7F50",
41
+ cornflowerblue: "6495ED",
42
+ cornsilk: "FFF8DC",
43
+ crimson: "DC143C",
44
+ cyan: "00FFFF",
45
+ darkblue: "00008B",
46
+ darkcyan: "008B8B",
47
+ darkgoldenrod: "B8860B",
48
+ darkgray: "A9A9A9",
49
+ darkgrey: "A9A9A9",
50
+ darkgreen: "006400",
51
+ darkkhaki: "BDB76B",
52
+ darkmagenta: "8B008B",
53
+ darkolivegreen: "556B2F",
54
+ darkorange: "FF8C00",
55
+ darkorchid: "9932CC",
56
+ darkred: "8B0000",
57
+ darksalmon: "E9967A",
58
+ darkseagreen: "8FBC8F",
59
+ darkslateblue: "483D8B",
60
+ darkslategray: "2F4F4F",
61
+ darkslategrey: "2F4F4F",
62
+ darkturquoise: "00CED1",
63
+ darkviolet: "9400D3",
64
+ deeppink: "FF1493",
65
+ deepskyblue: "00BFFF",
66
+ dimgray: "696969",
67
+ dimgrey: "696969",
68
+ dodgerblue: "1E90FF",
69
+ firebrick: "B22222",
70
+ floralwhite: "FFFAF0",
71
+ forestgreen: "228B22",
72
+ fuchsia: "FF00FF",
73
+ gainsboro: "DCDCDC",
74
+ ghostwhite: "F8F8FF",
75
+ gold: "FFD700",
76
+ goldenrod: "DAA520",
77
+ gray: "808080",
78
+ grey: "808080",
79
+ green: "00FF00",
80
+ greenyellow: "ADFF2F",
81
+ honeydew: "F0FFF0",
82
+ hotpink: "FF69B4",
83
+ indianred: "CD5C5C",
84
+ indigo: "4B0082",
85
+ ivory: "FFFFF0",
86
+ khaki: "F0E68C",
87
+ lavender: "E6E6FA",
88
+ lavenderblush: "FFF0F5",
89
+ lawngreen: "7CFC00",
90
+ lemonchiffon: "FFFACD",
91
+ lightblue: "ADD8E6",
92
+ lightcoral: "F08080",
93
+ lightcyan: "E0FFFF",
94
+ lightgoldenrodyellow: "FAFAD2",
95
+ lightgray: "D3D3D3",
96
+ lightgrey: "D3D3D3",
97
+ lightgreen: "90EE90",
98
+ lightpink: "FFB6C1",
99
+ lightsalmon: "FFA07A",
100
+ lightseagreen: "20B2AA",
101
+ lightskyblue: "87CEFA",
102
+ lightslategray: "778899",
103
+ lightslategrey: "778899",
104
+ lightsteelblue: "B0C4DE",
105
+ lightyellow: "FFFFE0",
106
+ lime: "00FF00",
107
+ limegreen: "32CD32",
108
+ linen: "FAF0E6",
109
+ magenta: "FF00FF",
110
+ maroon: "800000",
111
+ mediumaquamarine: "66CDAA",
112
+ mediumblue: "0000CD",
113
+ mediumorchid: "BA55D3",
114
+ mediumpurple: "9370DB",
115
+ mediumseagreen: "3CB371",
116
+ mediumslateblue: "7B68EE",
117
+ mediumspringgreen: "00FA9A",
118
+ mediumturquoise: "48D1CC",
119
+ mediumvioletred: "C71585",
120
+ midnightblue: "191970",
121
+ mintcream: "F5FFFA",
122
+ mistyrose: "FFE4E1",
123
+ moccasin: "FFE4B5",
124
+ navajowhite: "FFDEAD",
125
+ navy: "000080",
126
+ oldlace: "FDF5E6",
127
+ olive: "808000",
128
+ olivedrab: "6B8E23",
129
+ orange: "FFA500",
130
+ orangered: "FF4500",
131
+ orchid: "DA70D6",
132
+ palegoldenrod: "EEE8AA",
133
+ palegreen: "98FB98",
134
+ paleturquoise: "AFEEEE",
135
+ palevioletred: "DB7093",
136
+ papayawhip: "FFEFD5",
137
+ peachpuff: "FFDAB9",
138
+ peru: "CD853F",
139
+ pink: "FFC0CB",
140
+ plum: "DDA0DD",
141
+ powderblue: "B0E0E6",
142
+ purple: "800080",
143
+ rebeccapurple: "663399",
144
+ red: "FF0000",
145
+ rosybrown: "BC8F8F",
146
+ royalblue: "4169E1",
147
+ saddlebrown: "8B4513",
148
+ salmon: "FA8072",
149
+ sandybrown: "F4A460",
150
+ seagreen: "2E8B57",
151
+ seashell: "FFF5EE",
152
+ sienna: "A0522D",
153
+ silver: "C0C0C0",
154
+ skyblue: "87CEEB",
155
+ slateblue: "6A5ACD",
156
+ slategray: "708090",
157
+ slategrey: "708090",
158
+ snow: "FFFAFA",
159
+ springgreen: "00FF7F",
160
+ steelblue: "4682B4",
161
+ tan: "D2B48C",
162
+ teal: "008080",
163
+ thistle: "D8BFD8",
164
+ tomato: "FF6347",
165
+ turquoise: "40E0D0",
166
+ violet: "EE82EE",
167
+ wheat: "F5DEB3",
168
+ white: "FFFFFF",
169
+ whitesmoke: "F5F5F5",
170
+ yellow: "FFFF00",
171
+ yellowgreen: "9ACD32"
172
+ }.freeze
173
+
174
+ BORDER_STYLES = {
175
+ none: :none,
176
+ thin: :thin,
177
+ medium: :medium,
178
+ dashed: :dashed,
179
+ dotted: :dotted,
180
+ thick: :thick,
181
+ double: :double,
182
+ hair: :hair,
183
+ medium_dashed: :mediumDashed,
184
+ mediumdashed: :mediumDashed,
185
+ mediumDashed: :mediumDashed,
186
+ dash_dot: :dashDot,
187
+ dashdot: :dashDot,
188
+ dashDot: :dashDot,
189
+ medium_dash_dot: :mediumDashDot,
190
+ mediumdashdot: :mediumDashDot,
191
+ mediumDashDot: :mediumDashDot,
192
+ dash_dot_dot: :dashDotDot,
193
+ dashdotdot: :dashDotDot,
194
+ dashDotDot: :dashDotDot,
195
+ medium_dash_dot_dot: :mediumDashDotDot,
196
+ mediumdashdotdot: :mediumDashDotDot,
197
+ mediumDashDotDot: :mediumDashDotDot,
198
+ slant_dash_dot: :slantDashDot,
199
+ slantdashdot: :slantDashDot,
200
+ slantDashDot: :slantDashDot
201
+ }.freeze
202
+
203
+ BORDER_INDEX_MAP = {
204
+ 0 => :none,
205
+ 1 => :thin,
206
+ 2 => :medium,
207
+ 3 => :dashed,
208
+ 4 => :dotted,
209
+ 5 => :thick,
210
+ 6 => :double,
211
+ 7 => :hair,
212
+ 8 => :mediumDashed,
213
+ 9 => :dashDot,
214
+ 10 => :mediumDashDot,
215
+ 11 => :dashDotDot,
216
+ 12 => :mediumDashDotDot,
217
+ 13 => :slantDashDot
218
+ }.freeze
219
+
220
+ # Normalizes a color definition (CSS color name, hex code, or RGB integer) to an OpenXML hex color string.
221
+ #
222
+ # @example
223
+ # StyleBuilder.normalize_color(:navy) #=> "FF000080"
224
+ # StyleBuilder.normalize_color("#FF0000") #=> "FFFF0000"
225
+ # StyleBuilder.normalize_color("#F00") #=> "FFFF0000"
226
+ # StyleBuilder.normalize_color("FF0000", alpha: false) #=> "FF0000"
227
+ # StyleBuilder.normalize_color(0xFF0000) #=> "FFFF0000"
228
+ #
229
+ # @param color [String, Symbol, Integer, nil]
230
+ # @param alpha [Boolean] Whether to include the alpha channel ("AARRGGBB" vs "RRGGBB").
231
+ # @return [String, nil]
232
+ # @api public
233
+ #: (String | Symbol | Integer | nil color, ?alpha: bool) -> String?
234
+ def self.normalize_color(color, alpha: true)
235
+ return nil if color.nil?
236
+
237
+ hex = case color
238
+ when Symbol, defined?(String) && String
239
+ str = color.to_s.strip
240
+ return nil if str.empty?
241
+
242
+ # Strip leading colon (e.g. ":red")
243
+ str = str.sub(/^:/, "")
244
+ return nil if str.empty?
245
+
246
+ sym_key = str.downcase.to_sym
247
+ if CSS_COLORS.key?(sym_key)
248
+ CSS_COLORS[sym_key]
249
+ else
250
+ # Hex string parsing
251
+ clean_hex = str.sub(/^#/, "")
252
+ case clean_hex.length
253
+ when 3
254
+ if clean_hex.match?(/\A[0-9a-fA-F]{3}\z/)
255
+ r, g, b = clean_hex.chars
256
+ "#{r}#{r}#{g}#{g}#{b}#{b}".upcase
257
+ end
258
+ when 6
259
+ clean_hex.upcase if clean_hex.match?(/\A[0-9a-fA-F]{6}\z/)
260
+ when 8
261
+ if clean_hex.match?(/\A[0-9a-fA-F]{8}\z/)
262
+ return alpha ? clean_hex.upcase : clean_hex[2..7].upcase
263
+ end
264
+ end
265
+ end
266
+ when Integer
267
+ format("%06X", color & 0xFFFFFF)
268
+ end
269
+
270
+ return nil unless hex
271
+
272
+ alpha ? "FF#{hex}" : hex
273
+ end
274
+
275
+ # Normalizes a border style symbol, string, or integer index to an OOXML ST_BorderStyle symbol.
276
+ #
277
+ # @example
278
+ # StyleBuilder.normalize_border_style(:border_thin) #=> :thin
279
+ # StyleBuilder.normalize_border_style(1) #=> :thin
280
+ # StyleBuilder.normalize_border_style(:medium_dashed) #=> :mediumDashed
281
+ # StyleBuilder.normalize_border_style("double") #=> :double
282
+ # StyleBuilder.normalize_border_style(0) #=> :none
283
+ #
284
+ # @param style [Symbol, String, Integer, nil]
285
+ # @return [Symbol, nil]
286
+ # @api public
287
+ #: (Symbol | String | Integer | nil style) -> Symbol?
288
+ def self.normalize_border_style(style)
289
+ return nil if style.nil?
290
+
291
+ return BORDER_INDEX_MAP[style] if style.is_a?(Integer)
292
+
293
+ str = style.to_s.sub(/^border_/, "")
294
+ sym = str.to_sym
295
+ BORDER_STYLES[sym] || BORDER_STYLES[str.downcase.to_sym]
296
+ end
297
+
23
298
  # Resolves a color symbol, string, or prefix to an aRGB hex string.
24
299
  #
25
300
  # @param color [Symbol, String, Object, nil]
@@ -28,10 +303,15 @@ module Xlsxrb
28
303
  def self.resolve_color(color)
29
304
  return nil unless color
30
305
 
306
+ return Colors.palette_color(color, alpha: true) if color.is_a?(Integer) && defined?(Colors) && color >= 0 && color < Colors::INDEXED_COLORS.size
307
+
31
308
  if color.is_a?(Symbol) || (color.is_a?(String) && color.start_with?(":") && color.length > 1)
32
309
  key = color.to_s.sub(/^:/, "").to_sym
33
- return COLORS[key] || color.to_s
310
+ return COLORS[key] || (CSS_COLORS[key] ? "FF#{CSS_COLORS[key]}" : nil) || color.to_s
34
311
  end
312
+
313
+ return normalize_color(color, alpha: true) || color.to_s if color.is_a?(String) && color.start_with?("#")
314
+
35
315
  color.to_s
36
316
  end
37
317
 
@@ -314,75 +594,81 @@ module Xlsxrb
314
594
  end
315
595
 
316
596
  # Sets all borders.
317
- # @param style [String, Symbol] The border style.
597
+ # @param style [String, Symbol, Integer] The border style.
318
598
  # @param color [String, Symbol, nil] The color.
319
599
  # @return [self]
320
600
  # @api public
321
- #: (?style: String | Symbol, ?color: String | Symbol | nil) -> self
601
+ #: (?style: String | Symbol | Integer, ?color: String | Symbol | nil) -> self
322
602
  def border_all(style: "thin", color: nil)
603
+ norm_style = self.class.normalize_border_style(style)&.to_s || style.to_s
323
604
  color_opt = color ? { color: resolve_color(color) } : {}
324
- @border_props[:left] = { style: style, **color_opt }
325
- @border_props[:right] = { style: style, **color_opt }
326
- @border_props[:top] = { style: style, **color_opt }
327
- @border_props[:bottom] = { style: style, **color_opt }
605
+ @border_props[:left] = { style: norm_style, **color_opt }
606
+ @border_props[:right] = { style: norm_style, **color_opt }
607
+ @border_props[:top] = { style: norm_style, **color_opt }
608
+ @border_props[:bottom] = { style: norm_style, **color_opt }
328
609
  self
329
610
  end
330
611
 
331
612
  # Sets the left border.
332
- # @param style [String, Symbol] The border style.
613
+ # @param style [String, Symbol, Integer] The border style.
333
614
  # @param color [String, Symbol, nil] The color.
334
615
  # @return [self]
335
616
  # @api public
336
- #: (?style: String | Symbol, ?color: String | Symbol | nil) -> self
617
+ #: (?style: String | Symbol | Integer, ?color: String | Symbol | nil) -> self
337
618
  def border_left(style: "thin", color: nil)
338
- @border_props[:left] = { style: style, color: resolve_color(color) }.compact
619
+ norm_style = self.class.normalize_border_style(style)&.to_s || style.to_s
620
+ @border_props[:left] = { style: norm_style, color: resolve_color(color) }.compact
339
621
  self
340
622
  end
341
623
 
342
624
  # Sets the right border.
343
- # @param style [String, Symbol] The border style.
625
+ # @param style [String, Symbol, Integer] The border style.
344
626
  # @param color [String, Symbol, nil] The color.
345
627
  # @return [self]
346
628
  # @api public
347
- #: (?style: String | Symbol, ?color: String | Symbol | nil) -> self
629
+ #: (?style: String | Symbol | Integer, ?color: String | Symbol | nil) -> self
348
630
  def border_right(style: "thin", color: nil)
349
- @border_props[:right] = { style: style, color: resolve_color(color) }.compact
631
+ norm_style = self.class.normalize_border_style(style)&.to_s || style.to_s
632
+ @border_props[:right] = { style: norm_style, color: resolve_color(color) }.compact
350
633
  self
351
634
  end
352
635
 
353
636
  # Sets the top border.
354
- # @param style [String, Symbol] The border style.
637
+ # @param style [String, Symbol, Integer] The border style.
355
638
  # @param color [String, Symbol, nil] The color.
356
639
  # @return [self]
357
640
  # @api public
358
- #: (?style: String | Symbol, ?color: String | Symbol | nil) -> self
641
+ #: (?style: String | Symbol | Integer, ?color: String | Symbol | nil) -> self
359
642
  def border_top(style: "thin", color: nil)
360
- @border_props[:top] = { style: style, color: resolve_color(color) }.compact
643
+ norm_style = self.class.normalize_border_style(style)&.to_s || style.to_s
644
+ @border_props[:top] = { style: norm_style, color: resolve_color(color) }.compact
361
645
  self
362
646
  end
363
647
 
364
648
  # Sets the bottom border.
365
- # @param style [String, Symbol] The border style.
649
+ # @param style [String, Symbol, Integer] The border style.
366
650
  # @param color [String, Symbol, nil] The color.
367
651
  # @return [self]
368
652
  # @api public
369
- #: (?style: String | Symbol, ?color: String | Symbol | nil) -> self
653
+ #: (?style: String | Symbol | Integer, ?color: String | Symbol | nil) -> self
370
654
  def border_bottom(style: "thin", color: nil)
371
- @border_props[:bottom] = { style: style, color: resolve_color(color) }.compact
655
+ norm_style = self.class.normalize_border_style(style)&.to_s || style.to_s
656
+ @border_props[:bottom] = { style: norm_style, color: resolve_color(color) }.compact
372
657
  self
373
658
  end
374
659
 
375
660
  # rubocop:disable Naming/MethodParameterName
376
661
  # Sets diagonal borders.
377
- # @param style [String, Symbol] The border style.
662
+ # @param style [String, Symbol, Integer] The border style.
378
663
  # @param color [String, Symbol, nil] The color.
379
664
  # @param up [Boolean] Diagonal up.
380
665
  # @param down [Boolean] Diagonal down.
381
666
  # @return [self]
382
667
  # @api public
383
- #: (?style: String | Symbol, ?color: String | Symbol | nil, ?up: bool, ?down: bool) -> self
668
+ #: (?style: String | Symbol | Integer, ?color: String | Symbol | nil, ?up: bool, ?down: bool) -> self
384
669
  def border_diagonal(style: "thin", color: nil, up: false, down: false)
385
- @border_props[:diagonal] = { style: style, color: resolve_color(color) }.compact
670
+ norm_style = self.class.normalize_border_style(style)&.to_s || style.to_s
671
+ @border_props[:diagonal] = { style: norm_style, color: resolve_color(color) }.compact
386
672
  @border_props[:diagonal_up] = true if up
387
673
  @border_props[:diagonal_down] = true if down
388
674
  self
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "elements/cell"
6
+ require_relative "ooxml/utils"
7
+
8
+ module Xlsxrb
9
+ # Public coordinate conversion and reference parsing utilities.
10
+ #
11
+ # @api public
12
+ module Utils
13
+ # Converts a cell reference string (e.g. "A1", "BC100") to 0-based [row, col] coordinates.
14
+ #
15
+ # @example
16
+ # Xlsxrb::Utils.ref_to_row_col("A1") #=> [0, 0]
17
+ # Xlsxrb::Utils.ref_to_row_col("BC100") #=> [99, 54]
18
+ #
19
+ # @param cell_ref [String, nil] Excel cell reference string.
20
+ # @return [Array(Integer, Integer), nil] 0-based [row_idx, col_idx] tuple, or nil if invalid.
21
+ # @api public
22
+ #: (String? cell_ref) -> [Integer, Integer]?
23
+ def self.ref_to_row_col(cell_ref)
24
+ Elements::Cell.parse_ref(cell_ref)
25
+ end
26
+
27
+ # Converts 0-based [row_idx, col_idx] coordinates to an A1-style reference string.
28
+ #
29
+ # @example
30
+ # Xlsxrb::Utils.row_col_to_ref(0, 0) #=> "A1"
31
+ # Xlsxrb::Utils.row_col_to_ref(99, 54) #=> "BC100"
32
+ #
33
+ # @param row_idx [Integer] 0-based row index.
34
+ # @param col_idx [Integer] 0-based column index.
35
+ # @return [String] A1-style reference string.
36
+ # @api public
37
+ #: (Integer row_idx, Integer col_idx) -> String
38
+ def self.row_col_to_ref(row_idx, col_idx)
39
+ "#{Elements::Cell.column_letter(col_idx)}#{row_idx + 1}"
40
+ end
41
+
42
+ # Converts a column letter name (e.g. "A", "BC") to a 0-based integer index.
43
+ #
44
+ # @example
45
+ # Xlsxrb::Utils.col_name_to_index("A") #=> 0
46
+ # Xlsxrb::Utils.col_name_to_index("BC") #=> 54
47
+ #
48
+ # @param col_name [String, Symbol, Integer] Column letter or index.
49
+ # @return [Integer] 0-based column index.
50
+ # @api public
51
+ #: (String | Symbol | Integer col_name) -> Integer
52
+ def self.col_name_to_index(col_name)
53
+ Elements::Cell.column_index(col_name)
54
+ end
55
+
56
+ # Converts a 0-based column integer index to a column letter name (e.g. 0 -> "A", 54 -> "BC").
57
+ #
58
+ # @example
59
+ # Xlsxrb::Utils.col_index_to_name(0) #=> "A"
60
+ # Xlsxrb::Utils.col_index_to_name(54) #=> "BC"
61
+ #
62
+ # @param col_idx [Integer] 0-based column index.
63
+ # @return [String] Column letter name.
64
+ # @api public
65
+ #: (Integer col_idx) -> String
66
+ def self.col_index_to_name(col_idx)
67
+ Elements::Cell.column_letter(col_idx)
68
+ end
69
+
70
+ # Splits an A1-style cell reference into its column name and 1-based row number.
71
+ #
72
+ # @example
73
+ # Xlsxrb::Utils.split_coordinate("A1") #=> ["A", 1]
74
+ # Xlsxrb::Utils.split_coordinate("BC100") #=> ["BC", 100]
75
+ #
76
+ # @param cell_ref [String, nil] Excel cell reference string.
77
+ # @return [Array(String, Integer), nil] [col_name, row_number] tuple (1-based row), or nil if invalid.
78
+ # @api public
79
+ #: (String? cell_ref) -> [String, Integer]?
80
+ def self.split_coordinate(cell_ref)
81
+ return nil unless cell_ref
82
+
83
+ coords = Elements::Cell.parse_ref(cell_ref)
84
+ return nil unless coords
85
+
86
+ row_idx, col_idx = coords
87
+ [Elements::Cell.column_letter(col_idx), row_idx + 1]
88
+ end
89
+
90
+ # Converts a Date or Time to an Excel numeric serial number (1900 or 1904 system).
91
+ #
92
+ # @example
93
+ # Xlsxrb::Utils.date_to_serial(Date.new(2026, 1, 1)) #=> 46023
94
+ #
95
+ # @param date_or_time [Date, Time] Date or Time instance.
96
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
97
+ # @return [Integer, Float] Serial number.
98
+ # @api public
99
+ #: (Date | Time date_or_time, ?date1904: bool) -> (Integer | Float)
100
+ def self.date_to_serial(date_or_time, date1904: false)
101
+ if date_or_time.is_a?(Time)
102
+ Ooxml::Utils.datetime_to_serial(date_or_time, date1904: date1904)
103
+ else
104
+ Ooxml::Utils.date_to_serial(date_or_time, date1904: date1904)
105
+ end
106
+ end
107
+
108
+ # Converts an Excel serial number to a Date.
109
+ #
110
+ # @example
111
+ # Xlsxrb::Utils.serial_to_date(46023) #=> #<Date: 2026-01-01>
112
+ #
113
+ # @param serial [Numeric] Excel serial number.
114
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
115
+ # @return [Date]
116
+ # @api public
117
+ #: (Numeric serial, ?date1904: bool) -> Date
118
+ def self.serial_to_date(serial, date1904: false)
119
+ Ooxml::Utils.serial_to_date(serial, date1904: date1904)
120
+ end
121
+
122
+ # Converts a Time or Date to a fractional Excel serial number.
123
+ #
124
+ # @example
125
+ # Xlsxrb::Utils.datetime_to_serial(Time.utc(2026, 1, 1, 12, 0, 0)) #=> 46023.5
126
+ #
127
+ # @param time [Time, Date] Time or Date instance.
128
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
129
+ # @return [Float] Fractional serial number.
130
+ # @api public
131
+ #: (Time | Date time, ?date1904: bool) -> Float
132
+ def self.datetime_to_serial(time, date1904: false)
133
+ if time.is_a?(Time)
134
+ Ooxml::Utils.datetime_to_serial(time, date1904: date1904)
135
+ else
136
+ Ooxml::Utils.date_to_serial(time, date1904: date1904).to_f
137
+ end
138
+ end
139
+
140
+ # Converts an Excel serial number to a Time (UTC).
141
+ #
142
+ # @example
143
+ # Xlsxrb::Utils.serial_to_time(46023.5) #=> 2026-01-01 12:00:00 UTC
144
+ #
145
+ # @param serial [Numeric] Excel serial number.
146
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
147
+ # @return [Time]
148
+ # @api public
149
+ #: (Numeric serial, ?date1904: bool) -> Time
150
+ def self.serial_to_time(serial, date1904: false)
151
+ Ooxml::Utils.serial_to_datetime(serial, date1904: date1904)
152
+ end
153
+
154
+ class << self
155
+ alias serial_to_datetime serial_to_time
156
+ end
157
+ end
158
+ end
@@ -3,5 +3,5 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  module Xlsxrb
6
- VERSION = "0.1.13"
6
+ VERSION = "0.1.14"
7
7
  end
@@ -63,7 +63,12 @@ module Xlsxrb
63
63
  raise ArgumentError, "Sheet name '#{name}' contains invalid characters (ECMA-376 OOXML specification)" if name.match?(%r{[\[\]*?/\\]})
64
64
  raise ArgumentError, "Sheet name '#{name}' is already used. Excel requires unique sheet names." if @strict_excel_mode && @sheets.map { |s| s.respond_to?(:name) ? s.name.downcase : s.to_s.downcase }.include?(name.downcase)
65
65
 
66
- sheet_builder = WorksheetBuilder.new(name, strict_excel_mode: @strict_excel_mode)
66
+ state = opts.delete(:state)
67
+ hidden = opts.delete(:hidden)
68
+ state ||= :hidden if hidden
69
+ state ||= :visible
70
+
71
+ sheet_builder = WorksheetBuilder.new(name, strict_excel_mode: @strict_excel_mode, state: state)
67
72
  opts.each { |k, v| sheet_builder.sheet_properties(k, v) }
68
73
  yield sheet_builder if block_given?
69
74
  @sheet_builders << sheet_builder
@@ -112,6 +117,11 @@ module Xlsxrb
112
117
  #: (?rows: String?, ?cols: String?, ?sheet: String?) -> void
113
118
  def print_titles(rows: nil, cols: nil, sheet: nil)
114
119
  sheet_name = sheet || @sheets.last&.name || "Sheet1"
120
+ existing = @defined_names.find { |dn| dn[:name] == "_xlnm.Print_Titles" && dn[:local_sheet_name] == sheet_name }
121
+ if existing && (rows.nil? || cols.nil?)
122
+ cols = Regexp.last_match(1).delete("$") if cols.nil? && existing[:value] =~ /'[^']*'!\$([A-Za-z]+:\$[A-Za-z]+)/
123
+ rows = Regexp.last_match(1).delete("$") if rows.nil? && existing[:value] =~ /'[^']*'!\$([0-9]+:\$[0-9]+)/
124
+ end
115
125
  parts = []
116
126
  parts << "'#{sheet_name}'!$#{cols.sub(":", ":$")}" if cols
117
127
  parts << "'#{sheet_name}'!$#{rows.sub(":", ":$")}" if rows
@@ -120,6 +130,55 @@ module Xlsxrb
120
130
  defined_name("_xlnm.Print_Titles", value, sheet: sheet_name)
121
131
  end
122
132
 
133
+ # Sets repeating title rows for pagination on the target or active sheet.
134
+ #
135
+ # @example Repeat first row
136
+ # wb.repeat_rows(0)
137
+ #
138
+ # @param first [Integer, String] First row index (0-based) or range string (e.g. "1:2").
139
+ # @param last [Integer, String, nil] Optional last row index (0-based).
140
+ # @param sheet [String, nil] Target sheet name.
141
+ # @return [void]
142
+ # @api public
143
+ #: (Integer | String first, ?(Integer | String)? last, ?sheet: String?) -> void
144
+ def repeat_rows(first, last = nil, sheet: nil)
145
+ rows_str = if first.is_a?(String)
146
+ first.include?(":") ? first : "#{first}:#{last || first}"
147
+ else
148
+ last ||= first
149
+ "#{first + 1}:#{last + 1}"
150
+ end
151
+ print_titles(rows: rows_str, sheet: sheet)
152
+ end
153
+
154
+ # Sets repeating title columns for pagination on the target or active sheet.
155
+ #
156
+ # @example Repeat columns A through C
157
+ # wb.repeat_columns("A", "C")
158
+ #
159
+ # @param first [Integer, String] First column index (0-based) or column name/range.
160
+ # @param last [Integer, String, nil] Optional last column index/name.
161
+ # @param sheet [String, nil] Target sheet name.
162
+ # @return [void]
163
+ # @api public
164
+ #: (Integer | String first, ?(Integer | String)? last, ?sheet: String?) -> void
165
+ def repeat_columns(first, last = nil, sheet: nil)
166
+ cols_str = if first.is_a?(String)
167
+ first.include?(":") ? first : "#{first}:#{last || first}"
168
+ else
169
+ first_name = Utils.col_index_to_name(first)
170
+ last_name = if last.is_a?(Integer)
171
+ Utils.col_index_to_name(last)
172
+ elsif last
173
+ last.to_s
174
+ else
175
+ first_name
176
+ end
177
+ "#{first_name}:#{last_name}"
178
+ end
179
+ print_titles(cols: cols_str, sheet: sheet)
180
+ end
181
+
123
182
  # Sets workbook structure and window protection.
124
183
  #
125
184
  # @param opts [Hash] Protection options (e.g. lock_structure: true, password: "secret").
@@ -190,9 +249,16 @@ module Xlsxrb
190
249
  # Process styles from all sheets and collect style definitions
191
250
  processed_sheets, styles_definition = process_styles(@sheets)
192
251
 
252
+ # Auto-register print_area and print_titles defined names from sheet builders if not explicitly defined
253
+ @sheet_builders.each do |sb|
254
+ print_area(sb.print_area, sheet: sb.name) if sb.print_area && @defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Area" && dn[:local_sheet_name] == sb.name }
255
+ print_titles(rows: sb.print_titles[:rows], cols: sb.print_titles[:cols], sheet: sb.name) if sb.print_titles && @defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Titles" && dn[:local_sheet_name] == sb.name }
256
+ end
257
+
193
258
  # Store workbook-level metadata in unmapped_data
194
259
  wb_meta = {}
195
- wb_meta[:defined_names] = resolve_defined_names(@defined_names, processed_sheets) unless @defined_names.empty?
260
+ dns = resolve_defined_names(@defined_names, processed_sheets)
261
+ wb_meta[:defined_names] = dns unless dns.empty?
196
262
  wb_meta[:core_properties] = @core_properties unless @core_properties.empty?
197
263
  wb_meta[:app_properties] = @app_properties unless @app_properties.empty?
198
264
  wb_meta[:custom_properties] = @custom_properties unless @custom_properties.empty?
@@ -202,7 +268,8 @@ module Xlsxrb
202
268
  Elements::Workbook.new(
203
269
  sheets: processed_sheets,
204
270
  styles: styles_definition,
205
- unmapped_data: wb_meta.empty? ? {} : { facade: wb_meta }
271
+ unmapped_data: wb_meta.empty? ? {} : { facade: wb_meta },
272
+ defined_names: dns
206
273
  )
207
274
  end
208
275
 
@@ -275,6 +342,7 @@ module Xlsxrb
275
342
  height: row.height,
276
343
  hidden: row.hidden,
277
344
  custom_height: row.custom_height,
345
+ collapsed: row.collapsed,
278
346
  outline_level: row.outline_level,
279
347
  unmapped_data: row.unmapped_data,
280
348
  errors: row.errors
@@ -285,6 +353,8 @@ module Xlsxrb
285
353
  rows: new_rows,
286
354
  columns: sheet.columns,
287
355
  charts: sheet.charts,
356
+ conditional_formatting: sheet.conditional_formatting,
357
+ data_validations: sheet.data_validations,
288
358
  unmapped_data: sheet.unmapped_data,
289
359
  errors: sheet.errors
290
360
  )