write_xlsx 1.10.2 → 1.11.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -23,6 +23,7 @@ module Writexlsx
23
23
 
24
24
  MAX_DIGIT_WIDTH = 7 # For Calabri 11. # :nodoc:
25
25
  PADDING = 5 # :nodoc:
26
+ COLINFO = Struct.new('ColInfo', :width, :format, :hidden, :level, :collapsed, :autofit)
26
27
 
27
28
  attr_reader :index # :nodoc:
28
29
  attr_reader :charts, :images, :tables, :shapes, :drawings # :nodoc:
@@ -31,20 +32,25 @@ module Writexlsx
31
32
  attr_reader :vml_data_id # :nodoc:
32
33
  attr_reader :vml_header_id # :nodoc:
33
34
  attr_reader :autofilter_area # :nodoc:
34
- attr_reader :writer, :set_rows, :col_formats # :nodoc:
35
+ attr_reader :writer, :set_rows, :col_info # :nodoc:
35
36
  attr_reader :vml_shape_id # :nodoc:
36
37
  attr_reader :comments, :comments_author # :nodoc:
37
38
  attr_accessor :data_bars_2010, :dxf_priority # :nodoc:
38
39
  attr_reader :vba_codename # :nodoc:
39
- attr_writer :excel_version
40
+ attr_writer :excel_version # :nodoc:
41
+ attr_reader :filter_cells # :nodoc:
40
42
 
41
43
  def initialize(workbook, index, name) # :nodoc:
44
+ rowmax = 1_048_576
45
+ colmax = 16_384
46
+ strmax = 32_767
47
+
42
48
  @writer = Package::XMLWriterSimple.new
43
49
 
44
50
  @workbook = workbook
45
51
  @index = index
46
52
  @name = name
47
- @colinfo = {}
53
+ @col_info = {}
48
54
  @cell_data_table = []
49
55
  @excel_version = 2007
50
56
  @palette = workbook.palette
@@ -55,6 +61,10 @@ module Writexlsx
55
61
 
56
62
  @screen_gridlines = true
57
63
  @show_zeros = true
64
+
65
+ @xls_rowmax = rowmax
66
+ @xls_colmax = colmax
67
+ @xls_strmax = strmax
58
68
  @dim_rowmin = nil
59
69
  @dim_rowmax = nil
60
70
  @dim_colmin = nil
@@ -77,11 +87,10 @@ module Writexlsx
77
87
  @filter_on = false
78
88
  @filter_range = []
79
89
  @filter_cols = {}
90
+ @filter_cells = {}
80
91
  @filter_type = {}
81
92
 
82
- @col_sizes = {}
83
93
  @row_sizes = {}
84
- @col_formats = {}
85
94
 
86
95
  @last_shape_id = 1
87
96
  @rel_count = 0
@@ -90,8 +99,8 @@ module Writexlsx
90
99
  @external_drawing_links = []
91
100
  @external_comment_links = []
92
101
  @external_vml_links = []
93
- @external_table_links = []
94
102
  @external_background_links = []
103
+ @external_table_links = []
95
104
  @drawing_links = []
96
105
  @vml_drawing_links = []
97
106
  @charts = []
@@ -121,6 +130,7 @@ module Writexlsx
121
130
  @default_col_width = 8.43
122
131
  @default_col_pixels = 64
123
132
  @default_row_rezoed = 0
133
+ @default_date_pixels = 68
124
134
 
125
135
  @merge = []
126
136
 
@@ -305,7 +315,8 @@ module Writexlsx
305
315
  #
306
316
  def set_column(*args)
307
317
  # Check for a cell reference in A1 notation and substitute row and column
308
- if args[0].to_s =~ /^\D/
318
+ # ruby 3.2 no longer handles =~ for various types
319
+ if args[0].respond_to?(:=~) && args[0].to_s =~ /^\D/
309
320
  _row1, firstcol, _row2, lastcol, *data = substitute_cellref(*args)
310
321
  else
311
322
  firstcol, lastcol, *data = args
@@ -321,6 +332,7 @@ module Writexlsx
321
332
  firstcol, lastcol = lastcol, firstcol if firstcol > lastcol
322
333
 
323
334
  width, format, hidden, level, collapsed = data
335
+ autofit = 0
324
336
 
325
337
  # Check that cols are valid and store max and min values with default row.
326
338
  # NOTE: The check shouldn't modify the row dimensions and should only modify
@@ -338,22 +350,19 @@ module Writexlsx
338
350
  level = 0 if level < 0
339
351
  level = 7 if level > 7
340
352
 
353
+ # Excel has a maximum column width of 255 characters.
354
+ width = 255.0 if width && width > 255.0
355
+
341
356
  @outline_col_level = level if level > @outline_col_level
342
357
 
343
358
  # Store the column data based on the first column. Padded for sorting.
344
- @colinfo[sprintf("%05d", firstcol)] = [firstcol, lastcol, width, format, hidden, level, collapsed]
359
+ (firstcol..lastcol).each do |col|
360
+ @col_info[col] =
361
+ COLINFO.new(width, format, hidden, level, collapsed, autofit)
362
+ end
345
363
 
346
364
  # Store the column change to allow optimisations.
347
365
  @col_size_changed = 1
348
-
349
- # Store the col sizes for use when calculating image vertices taking
350
- # hidden columns into account. Also store the column formats.
351
- width ||= @default_col_width
352
-
353
- (firstcol..lastcol).each do |col|
354
- @col_sizes[col] = [width, hidden]
355
- @col_formats[col] = format if format
356
- end
357
366
  end
358
367
 
359
368
  #
@@ -387,6 +396,112 @@ module Writexlsx
387
396
  set_column(first_col, last_col, width, format, hidden, level)
388
397
  end
389
398
 
399
+ #
400
+ # autofit()
401
+ #
402
+ # Simulate autofit based on the data, and datatypes in each column. We do this
403
+ # by estimating a pixel width for each cell data.
404
+ #
405
+ def autofit
406
+ col_width = {}
407
+
408
+ # Iterate through all the data in the worksheet.
409
+ (@dim_rowmin..@dim_rowmax).each do |row_num|
410
+ # Skip row if it doesn't contain cell data.
411
+ next unless @cell_data_table[row_num]
412
+
413
+ (@dim_colmin..@dim_colmax).each do |col_num|
414
+ length = 0
415
+ case (cell_data = @cell_data_table[row_num][col_num])
416
+ when StringCellData, RichStringCellData
417
+ # Handle strings and rich strings.
418
+ #
419
+ # For standard shared strings we do a reverse lookup
420
+ # from the shared string id to the actual string. For
421
+ # rich strings we use the unformatted string. We also
422
+ # split multiline strings and handle each part
423
+ # separately.
424
+ string = cell_data.raw_string
425
+
426
+ if string =~ /\n/
427
+ # Handle multiline strings.
428
+ length = max = string.split("\n").collect do |str|
429
+ xl_string_pixel_width(str)
430
+ end.max
431
+ else
432
+ length = xl_string_pixel_width(string)
433
+ end
434
+ when DateTimeCellData
435
+
436
+ # Handle dates.
437
+ #
438
+ # The following uses the default width for mm/dd/yyyy
439
+ # dates. It isn't feasible to parse the number format
440
+ # to get the actual string width for all format types.
441
+ length = @default_date_pixels
442
+ when NumberCellData
443
+
444
+ # Handle numbers.
445
+ #
446
+ # We use a workaround/optimization for numbers since
447
+ # digits all have a pixel width of 7. This gives a
448
+ # slightly greater width for the decimal place and
449
+ # minus sign but only by a few pixels and
450
+ # over-estimation is okay.
451
+ length = 7 * cell_data.token.to_s.length
452
+ when BooleanCellData
453
+
454
+ # Handle boolean values.
455
+ #
456
+ # Use the Excel standard widths for TRUE and FALSE.
457
+ if ptrue?(cell_data.token)
458
+ length = 31
459
+ else
460
+ length = 36
461
+ end
462
+ when FormulaCellData, FormulaArrayCellData, DynamicFormulaArrayCellData
463
+ # Handle formulas.
464
+ #
465
+ # We only try to autofit a formula if it has a
466
+ # non-zero value.
467
+ if ptrue?(cell_data.data)
468
+ length = xl_string_pixel_width(cell_data.data)
469
+ end
470
+ end
471
+
472
+ # If the cell is in an autofilter header we add an
473
+ # additional 16 pixels for the dropdown arrow.
474
+ if length > 0 &&
475
+ @filter_cells["#{row_num}:#{col_num}"]
476
+ length += 16
477
+ end
478
+
479
+ # Add the string lenght to the lookup hash.
480
+ max = col_width[col_num] || 0
481
+ col_width[col_num] = length if length > max
482
+ end
483
+ end
484
+
485
+ # Apply the width to the column.
486
+ col_width.each do |col_num, pixel_width|
487
+ # Convert the string pixel width to a character width using an
488
+ # additional padding of 7 pixels, like Excel.
489
+ width = pixels_to_width(pixel_width + 7)
490
+
491
+ # The max column character width in Excel is 255.
492
+ width = 255.0 if width > 255.0
493
+
494
+ # Add the width to an existing col info structure or add a new one.
495
+ if @col_info[col_num]
496
+ @col_info[col_num].width = width
497
+ @col_info[col_num].autofit = 1
498
+ else
499
+ @col_info[col_num] =
500
+ COLINFO.new(width, nil, 0, 0, 0, 1)
501
+ end
502
+ end
503
+ end
504
+
390
505
  #
391
506
  # :call-seq:
392
507
  # set_selection(cell_or_cell_range)
@@ -936,31 +1051,35 @@ module Writexlsx
936
1051
  write_row(_row, _col, _token, _format, _value1, _value2)
937
1052
  elsif _token.respond_to?(:coerce) # Numeric
938
1053
  write_number(_row, _col, _token, _format)
939
- # Match integer with leading zero(s)
940
- elsif @leading_zeros && _token =~ /^0\d*$/
941
- write_string(_row, _col, _token, _format)
942
- elsif _token =~ /\A([+-]?)(?=\d|\.\d)\d*(\.\d*)?([Ee]([+-]?\d+))?\Z/
943
- write_number(_row, _col, _token, _format)
944
- # Match formula
945
- elsif _token =~ /^=/
946
- write_formula(_row, _col, _token, _format, _value1)
947
- # Match array formula
948
- elsif _token =~ /^\{=.*\}$/
949
- write_formula(_row, _col, _token, _format, _value1)
950
- # Match blank
951
- elsif _token == ''
952
- # row_col_args.delete_at(2) # remove the empty string from the parameter list
953
- write_blank(_row, _col, _format)
954
- elsif @workbook.strings_to_urls
955
- # Match http, https or ftp URL
956
- if _token =~ %r{\A[fh]tt?ps?://}
957
- write_url(_row, _col, _token, _format, _value1, _value2)
958
- # Match mailto:
959
- elsif _token =~ /\Amailto:/
960
- write_url(_row, _col, _token, _format, _value1, _value2)
961
- # Match internal or external sheet link
962
- elsif _token =~ /\A(?:in|ex)ternal:/
963
- write_url(_row, _col, _token, _format, _value1, _value2)
1054
+ elsif _token.respond_to?(:=~) # String
1055
+ # Match integer with leading zero(s)
1056
+ if @leading_zeros && _token =~ /^0\d*$/
1057
+ write_string(_row, _col, _token, _format)
1058
+ elsif _token =~ /\A([+-]?)(?=\d|\.\d)\d*(\.\d*)?([Ee]([+-]?\d+))?\Z/
1059
+ write_number(_row, _col, _token, _format)
1060
+ # Match formula
1061
+ elsif _token =~ /^=/
1062
+ write_formula(_row, _col, _token, _format, _value1)
1063
+ # Match array formula
1064
+ elsif _token =~ /^\{=.*\}$/
1065
+ write_formula(_row, _col, _token, _format, _value1)
1066
+ # Match blank
1067
+ elsif _token == ''
1068
+ # row_col_args.delete_at(2) # remove the empty string from the parameter list
1069
+ write_blank(_row, _col, _format)
1070
+ elsif @workbook.strings_to_urls
1071
+ # Match http, https or ftp URL
1072
+ if _token =~ %r{\A[fh]tt?ps?://}
1073
+ write_url(_row, _col, _token, _format, _value1, _value2)
1074
+ # Match mailto:
1075
+ elsif _token =~ /\Amailto:/
1076
+ write_url(_row, _col, _token, _format, _value1, _value2)
1077
+ # Match internal or external sheet link
1078
+ elsif _token =~ /\A(?:in|ex)ternal:/
1079
+ write_url(_row, _col, _token, _format, _value1, _value2)
1080
+ else
1081
+ write_string(_row, _col, _token, _format)
1082
+ end
964
1083
  else
965
1084
  write_string(_row, _col, _token, _format)
966
1085
  end
@@ -1114,7 +1233,7 @@ module Writexlsx
1114
1233
 
1115
1234
  index = shared_string_index(_string.length > STR_MAX ? _string[0, STR_MAX] : _string)
1116
1235
 
1117
- store_data_to_table(StringCellData.new(index, _format), _row, _col)
1236
+ store_data_to_table(StringCellData.new(index, _format, _string), _row, _col)
1118
1237
  end
1119
1238
 
1120
1239
  #
@@ -1143,13 +1262,16 @@ module Writexlsx
1143
1262
  check_dimensions(_row, _col)
1144
1263
  store_row_col_max_min_values(_row, _col)
1145
1264
 
1146
- _fragments, _length = rich_strings_fragments(_rich_strings)
1265
+ _fragments, _raw_string = rich_strings_fragments(_rich_strings)
1147
1266
  # can't allow 2 formats in a row
1148
1267
  return -4 unless _fragments
1149
1268
 
1269
+ # Check that the string si < 32767 chars.
1270
+ return 3 if _raw_string.size > @xls_strmax
1271
+
1150
1272
  index = shared_string_index(xml_str_of_rich_string(_fragments))
1151
1273
 
1152
- store_data_to_table(StringCellData.new(index, _xf), _row, _col)
1274
+ store_data_to_table(RichStringCellData.new(index, _xf, _raw_string), _row, _col)
1153
1275
  end
1154
1276
 
1155
1277
  #
@@ -1679,7 +1801,7 @@ module Writexlsx
1679
1801
  date_time = convert_date_time(_str)
1680
1802
 
1681
1803
  if date_time
1682
- store_data_to_table(NumberCellData.new(date_time, _format), _row, _col)
1804
+ store_data_to_table(DateTimeCellData.new(date_time, _format), _row, _col)
1683
1805
  else
1684
1806
  # If the date isn't valid then write it as a string.
1685
1807
  write_string(_row, _col, _str, _format)
@@ -2164,6 +2286,11 @@ module Writexlsx
2164
2286
  @autofilter_area = convert_name_area(_row1, _col1, _row2, _col2)
2165
2287
  @autofilter_ref = xl_range(_row1, _row2, _col1, _col2)
2166
2288
  @filter_range = [_col1, _col2]
2289
+
2290
+ # Store the filter cell positions for use in the autofit calculation.
2291
+ (_col1.._col2).each do |col|
2292
+ @filter_cells["#{_row1}:#{col}"] = 1
2293
+ end
2167
2294
  end
2168
2295
 
2169
2296
  #
@@ -2506,8 +2633,8 @@ module Writexlsx
2506
2633
  @external_hyper_links,
2507
2634
  @external_drawing_links,
2508
2635
  @external_vml_links,
2509
- @external_table_links,
2510
2636
  @external_background_links,
2637
+ @external_table_links,
2511
2638
  @external_comment_links
2512
2639
  ].reject { |a| a.empty? }
2513
2640
  end
@@ -2634,6 +2761,33 @@ module Writexlsx
2634
2761
 
2635
2762
  private
2636
2763
 
2764
+ #
2765
+ # Compare adjacent column information structures.
2766
+ #
2767
+ def compare_col_info(col_options, previous_options)
2768
+ if !col_options.width.nil? != !previous_options.width.nil?
2769
+ return nil
2770
+ end
2771
+ if col_options.width && previous_options.width &&
2772
+ col_options.width != previous_options.width
2773
+ return nil
2774
+ end
2775
+
2776
+ if !col_options.format.nil? != !previous_options.format.nil?
2777
+ return nil
2778
+ end
2779
+ if col_options.format && previous_options.format &&
2780
+ col_options.format != previous_options.format
2781
+ return nil
2782
+ end
2783
+
2784
+ return nil if col_options.hidden != previous_options.hidden
2785
+ return nil if col_options.level != previous_options.level
2786
+ return nil if col_options.collapsed != previous_options.collapsed
2787
+
2788
+ true
2789
+ end
2790
+
2637
2791
  #
2638
2792
  # Get the index used to address a drawing rel link.
2639
2793
  #
@@ -2685,9 +2839,9 @@ module Writexlsx
2685
2839
  # Create a temp format with the default font for unformatted fragments.
2686
2840
  default = Format.new(0)
2687
2841
 
2688
- length = 0 # String length.
2689
2842
  last = 'format'
2690
2843
  pos = 0
2844
+ raw_string = ''
2691
2845
 
2692
2846
  fragments = []
2693
2847
  rich_strings.each do |token|
@@ -2708,12 +2862,12 @@ module Writexlsx
2708
2862
  fragments << default << token
2709
2863
  end
2710
2864
 
2711
- length += token.size # Keep track of actual string length.
2865
+ raw_string += token # Keep track of actual string length.
2712
2866
  last = 'string'
2713
2867
  end
2714
2868
  pos += 1
2715
2869
  end
2716
- [fragments, length]
2870
+ [fragments, raw_string]
2717
2871
  end
2718
2872
 
2719
2873
  def xml_str_of_rich_string(fragments)
@@ -2944,8 +3098,9 @@ module Writexlsx
2944
3098
  #
2945
3099
  def size_col(col, anchor = 0) # :nodoc:
2946
3100
  # Look up the cell value to see if it has been changed.
2947
- if @col_sizes[col]
2948
- width, hidden = @col_sizes[col]
3101
+ if @col_info[col]
3102
+ width = @col_info[col].width || @default_col_width
3103
+ hidden = @col_info[col].hidden
2949
3104
 
2950
3105
  # Convert to pixels.
2951
3106
  pixels = if hidden == 1 && anchor != 4
@@ -3276,28 +3431,22 @@ EOS
3276
3431
  end
3277
3432
 
3278
3433
  #
3279
- # Based on the algorithm provided by Daniel Rentz of OpenOffice.
3280
- #
3434
+ # Hash a worksheet password. Based on the algorithm in ECMA-376-4:2016,
3435
+ # Office Open XML File Foemats -- Transitional Migration Features,
3436
+ # Additional attributes for workbookProtection element (Part 1, §18.2.29). #
3281
3437
  def encode_password(password) # :nodoc:
3282
- i = 0
3283
- chars = password.split(//)
3284
- count = chars.size
3438
+ hash = 0
3285
3439
 
3286
- chars.collect! do |char|
3287
- i += 1
3288
- char = char.ord << i
3289
- low_15 = char & 0x7fff
3290
- high_15 = char & (0x7fff << 15)
3291
- high_15 = high_15 >> 15
3292
- char = low_15 | high_15
3440
+ password.reverse.split(//).each do |char|
3441
+ hash = ((hash >> 14) & 0x01) | ((hash << 1) & 0x7fff)
3442
+ hash ^= char.ord
3293
3443
  end
3294
3444
 
3295
- encoded_password = 0x0000
3296
- chars.each { |c| encoded_password ^= c }
3297
- encoded_password ^= count
3298
- encoded_password ^= 0xCE4B
3445
+ hash = ((hash >> 14) & 0x01) | ((hash << 1) & 0x7fff)
3446
+ hash ^= password.length
3447
+ hash ^= 0xCE4B
3299
3448
 
3300
- sprintf("%X", encoded_password)
3449
+ sprintf("%X", hash)
3301
3450
  end
3302
3451
 
3303
3452
  #
@@ -3475,10 +3624,42 @@ EOS
3475
3624
  #
3476
3625
  def write_cols # :nodoc:
3477
3626
  # Exit unless some column have been formatted.
3478
- return if @colinfo.empty?
3627
+ return if @col_info.empty?
3479
3628
 
3480
3629
  @writer.tag_elements('cols') do
3481
- @colinfo.keys.sort.each { |col| write_col_info(@colinfo[col]) }
3630
+ # Use the first element of the column informatin structure to set
3631
+ # the initial/previous properties.
3632
+ first_col = @col_info.keys.min
3633
+ last_col = first_col
3634
+ previous_options = @col_info[first_col]
3635
+ deleted_col = first_col
3636
+ deleted_col_options = previous_options
3637
+
3638
+ @col_info.delete(first_col)
3639
+
3640
+ @col_info.keys.sort.each do |col|
3641
+ col_options = @col_info[col]
3642
+
3643
+ # Check if the column number is contiguous with the previous
3644
+ # column and if the properties are the same.
3645
+ if (col == last_col + 1) &&
3646
+ compare_col_info(col_options, previous_options)
3647
+ last_col = col
3648
+ else
3649
+ # If not contiguous/equal then we write out the current range
3650
+ # of columns and start again.
3651
+ write_col_info([first_col, last_col, previous_options])
3652
+ first_col = col
3653
+ last_col = first_col
3654
+ previous_options = col_options
3655
+ end
3656
+ end
3657
+
3658
+ # We will exit the previous loop with one unhandled column range.
3659
+ write_col_info([first_col, last_col, previous_options])
3660
+
3661
+ # Put back the deleted first column information structure:
3662
+ @col_info[deleted_col] = deleted_col_options
3482
3663
  end
3483
3664
  end
3484
3665
 
@@ -3490,13 +3671,14 @@ EOS
3490
3671
  end
3491
3672
 
3492
3673
  def col_info_attributes(args)
3493
- min = args[0] || 0 # First formatted column.
3494
- max = args[1] || 0 # Last formatted column.
3495
- width = args[2] # Col width in user units.
3496
- format = args[3] # Format index.
3497
- hidden = args[4] || 0 # Hidden flag.
3498
- level = args[5] || 0 # Outline level.
3499
- collapsed = args[6] || 0 # Outline level.
3674
+ min = args[0] || 0 # First formatted column.
3675
+ max = args[1] || 0 # Last formatted column.
3676
+ width = args[2].width # Col width in user units.
3677
+ format = args[2].format # Format index.
3678
+ hidden = args[2].hidden || 0 # Hidden flag.
3679
+ level = args[2].level || 0 # Outline level.
3680
+ collapsed = args[2].collapsed || 0 # Outline Collapsed
3681
+ autofit = args[2].autofit || 0 # Best fit for autofit numbers.
3500
3682
  xf_index = format ? format.get_xf_index : 0
3501
3683
 
3502
3684
  custom_width = true
@@ -3519,10 +3701,11 @@ EOS
3519
3701
  ['width', width]
3520
3702
  ]
3521
3703
 
3522
- attributes << ['style', xf_index] if xf_index != 0
3523
- attributes << ['hidden', 1] if hidden != 0
3704
+ attributes << ['style', xf_index] if xf_index != 0
3705
+ attributes << ['hidden', 1] if hidden != 0
3706
+ attributes << ['bestFit', 1] if autofit != 0
3524
3707
  attributes << ['customWidth', 1] if custom_width
3525
- attributes << ['outlineLevel', level] if level != 0
3708
+ attributes << ['outlineLevel', level] if level != 0
3526
3709
  attributes << ['collapsed', 1] if collapsed != 0
3527
3710
  attributes
3528
3711
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: write_xlsx
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.10.2
4
+ version: 1.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hideo NAKAMURA
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-02-16 00:00:00.000000000 Z
11
+ date: 2023-08-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubyzip
@@ -130,6 +130,7 @@ files:
130
130
  - examples/add_vba_project.rb
131
131
  - examples/array_formula.rb
132
132
  - examples/autofilter.rb
133
+ - examples/autofit.rb
133
134
  - examples/background.rb
134
135
  - examples/chart_area.rb
135
136
  - examples/chart_bar.rb
@@ -275,7 +276,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
275
276
  - !ruby/object:Gem::Version
276
277
  version: '0'
277
278
  requirements: []
278
- rubygems_version: 3.4.5
279
+ rubygems_version: 3.4.10
279
280
  signing_key:
280
281
  specification_version: 4
281
282
  summary: write_xlsx is a gem to create a new file in the Excel 2007+ XLSX format.