calcsheet 0.1.0 → 0.2.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +124 -9
  4. data/lib/calcsheet/book.rb +25 -9
  5. data/lib/calcsheet/cell_range.rb +54 -0
  6. data/lib/calcsheet/configuration.rb +10 -0
  7. data/lib/calcsheet/display_format.rb +75 -0
  8. data/lib/calcsheet/errors.rb +5 -0
  9. data/lib/calcsheet/formula.rb +40 -0
  10. data/lib/calcsheet/limited_inflater.rb +16 -0
  11. data/lib/calcsheet/native_backends.rb +139 -0
  12. data/lib/calcsheet/option_validation.rb +4 -1
  13. data/lib/calcsheet/raw_row.rb +1 -1
  14. data/lib/calcsheet/readers/csv_reader.rb +43 -20
  15. data/lib/calcsheet/readers/csv_records.rb +193 -0
  16. data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
  17. data/lib/calcsheet/readers/ods/scanner.rb +33 -6
  18. data/lib/calcsheet/readers/ods_reader.rb +16 -1
  19. data/lib/calcsheet/readers/sax_sheet.rb +6 -48
  20. data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
  21. data/lib/calcsheet/reading.rb +29 -7
  22. data/lib/calcsheet/row.rb +2 -2
  23. data/lib/calcsheet/row_builder.rb +235 -0
  24. data/lib/calcsheet/sheet_list.rb +25 -0
  25. data/lib/calcsheet/sheet_selection.rb +3 -2
  26. data/lib/calcsheet/style.rb +114 -0
  27. data/lib/calcsheet/version.rb +1 -1
  28. data/lib/calcsheet/write_book.rb +49 -17
  29. data/lib/calcsheet/write_sheet.rb +220 -18
  30. data/lib/calcsheet/writers/csv_writer.rb +18 -7
  31. data/lib/calcsheet/writers/ods_writer.rb +249 -16
  32. data/lib/calcsheet/writers/open_formula.rb +67 -0
  33. data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
  34. data/lib/calcsheet/writers/zip_buffered.rb +20 -1
  35. data/lib/calcsheet.rb +31 -8
  36. metadata +11 -2
@@ -8,7 +8,9 @@ require_relative "../column_letter"
8
8
  require_relative "../decimal_text"
9
9
  require_relative "../errors"
10
10
  require_relative "../excel_serial"
11
+ require_relative "../formula"
11
12
  require_relative "../grid"
13
+ require_relative "../style"
12
14
  require_relative "xml_text"
13
15
  require_relative "zip_buffered"
14
16
 
@@ -20,9 +22,10 @@ module Calcsheet
20
22
  # "+39..." and "=..." stay verbatim. The parts that list every sheet (workbook, relationships, content
21
23
  # types) and the styles, known only at the end, are written after the sheets; zip entries have no required order.
22
24
  #
23
- # Styles: the default, the bold header, and one per number format a column needs: decimals with +scale+ (the
24
- # full value is stored, the format shows +scale+ decimals), dates and datetimes. They are numbered in the order
25
- # the columns first use them.
25
+ # Styles: the default, the bold header, one per number format a column needs: decimals with +scale+ (the
26
+ # full value is stored, the format shows +scale+ decimals), dates and datetimes, and one per Style and number
27
+ # format the rows use (fonts, fills and borders shared between them). They are numbered in the order they are
28
+ # first used. A formula is written without a cached value, and the workbook asks to be computed when it opens.
26
29
  #
27
30
  # @api private
28
31
  class XlsxWriter
@@ -65,30 +68,45 @@ module Calcsheet
65
68
  @styles = {}
66
69
  @cell_formats = []
67
70
  @number_formats = {}
71
+ @fonts = { [ nil, nil, nil, nil ] => 0, [ true, nil, nil, nil ] => 1 }
72
+ @fills = { nil => 0, gray125: 1 }
73
+ @borders = { nil => 0 }
74
+ @letters_cache = {}
75
+ @formulas = false
68
76
  @buffer = new_buffer
69
77
  @entry = nil
78
+ @deferred = nil
70
79
  end
71
80
 
72
- # Opens the sheet's zip entry and writes its start, column widths and header row.
81
+ # Opens the sheet's zip entry and writes its start, column widths and header row; with +auto_width+, the rows
82
+ # go to a temporary file and the entry is written when the sheet ends, its widths known.
73
83
  # @raise [ArgumentError] for more columns than XLSX holds
74
84
  # @raise [WriteError] for a header longer than a cell holds
75
- def start_sheet(name, columns, header:)
76
- check_sheet(name, columns)
85
+ def start_sheet(name, columns, header:, widths: nil, auto_width: false)
86
+ check_sheet(name, columns) if columns
77
87
  @sheet_names << name
78
88
  @name = name
79
89
  @columns = columns
80
- @types = columns.map(&:type)
90
+ @types = columns&.map(&:type)
91
+ @header = header
81
92
  @row_number = 0
82
- prepare_cells(columns)
83
- @entry = @zip.write_deflated_file("xl/worksheets/sheet#{@sheet_names.size}.xml")
84
- @buffer << sheet_start(columns, header)
93
+ @merges = []
94
+ prepare_cells(columns) if columns
95
+ if auto_width
96
+ defer_rows
97
+ else
98
+ open_sheet_entry(cols(columns ? columns.map(&:width) : widths || []))
99
+ end
85
100
  write_header(columns) if header
86
101
  end
87
102
 
88
- # Writes one row of checked values (see the contract in WriteBook) from the constant parts of #prepare_cells.
103
+ # Writes one row of checked values (see the contract in WriteBook): a typed row without styles or formulas from
104
+ # the constant parts of #prepare_cells, any other cell by cell.
89
105
  # @raise [WriteError] for a date beyond the serials or a text longer than a cell; nothing of the row is written
90
- def row(values)
106
+ def row(values, styles = nil)
91
107
  mark = @buffer.bytesize
108
+ return any_row(values, styles) if styles || @types.nil? || values.any?(Formula)
109
+
92
110
  number = (@row_number + 1).to_s
93
111
  @buffer << '<row r="' << number << '">'
94
112
  index = 0
@@ -110,10 +128,25 @@ module Calcsheet
110
128
  raise
111
129
  end
112
130
 
113
- # Ends the sheet's XML and closes its zip entry.
114
- def end_sheet
115
- @buffer << "</sheetData></worksheet>"
116
- flush
131
+ # @param range [CellRange] written after the rows, as XLSX keeps them
132
+ def merge(range)
133
+ @merges << range
134
+ end
135
+
136
+ # Ends the sheet's XML and closes its zip entry; with +auto_width+, writes the entry with +widths+ first.
137
+ def end_sheet(widths = nil)
138
+ @buffer << "</sheetData>" << merge_cells << "</worksheet>"
139
+ if @deferred
140
+ tail = @buffer.dup
141
+ @buffer.clear
142
+ flush
143
+ open_sheet_entry(cols(widths || []))
144
+ flush
145
+ replay_deferred
146
+ @entry << tail
147
+ else
148
+ flush
149
+ end
117
150
  @entry.close
118
151
  @entry = nil
119
152
  end
@@ -137,7 +170,7 @@ module Calcsheet
137
170
  next if value.nil?
138
171
 
139
172
  reason =
140
- case @types[index]
173
+ case type_of(value, index)
141
174
  # A text of at most 32,767 bytes has at most as many UTF-16 code units (a UTF-8 character takes at least as
142
175
  # many bytes as it takes units): only a longer one is counted.
143
176
  when :string then length_reason(excel_length(value)) if value.bytesize > Grid::MAX_CELL_CHARACTERS && excel_length(value) > Grid::MAX_CELL_CHARACTERS
@@ -197,8 +230,10 @@ module Calcsheet
197
230
  # +[numFmtId, fontId]+: the header is the General format (0) in the bold font (1, see #styles_xml), a number
198
231
  # format its custom format in the default font (0).
199
232
  def style(key)
233
+ return 0 if key.nil?
234
+
200
235
  @styles[key] ||= begin
201
- @cell_formats << (key == HEADER_STYLE ? [ 0, 1 ] : [ number_format(key), 0 ])
236
+ @cell_formats << (key == HEADER_STYLE ? [ 0, 1, 0, 0, nil ] : [ number_format(key), 0, 0, 0, nil ])
202
237
  @cell_formats.size
203
238
  end
204
239
  end
@@ -207,30 +242,151 @@ module Calcsheet
207
242
  @number_formats[code] ||= FIRST_CUSTOM_FORMAT + @number_formats.size
208
243
  end
209
244
 
210
- def sheet_start(columns, header)
245
+ # Opens the sheet's zip entry with its start, up to the rows.
246
+ def open_sheet_entry(cols_xml)
247
+ @entry = @zip.write_deflated_file("xl/worksheets/sheet#{@sheet_names.size}.xml")
248
+ @entry << sheet_start(cols_xml, @header)
249
+ end
250
+
251
+ def sheet_start(cols_xml, header)
211
252
  xml = +DECLARATION
212
253
  xml << %(<worksheet xmlns="#{MAIN_NS}" xmlns:r="#{REL_NS}"><sheetViews><sheetView)
213
254
  xml << ' tabSelected="1"' if @sheet_names.size == 1
214
255
  xml << ' workbookViewId="0"'
215
256
  xml << (header ? ">#{FROZEN_HEADER}</sheetView>" : "/>")
216
257
  xml << "</sheetViews>"
217
- xml << widths(columns)
258
+ xml << cols_xml
218
259
  xml << "<sheetData>"
219
260
  end
220
261
 
221
- def widths(columns)
222
- cols = columns.each_with_index.filter_map do |column, index|
223
- next if column.width.nil?
262
+ # @param widths [Array<Numeric, nil>] by column index
263
+ def cols(widths)
264
+ cols = widths.each_with_index.filter_map do |width, index|
265
+ next if width.nil?
224
266
 
225
- %(<col min="#{index + 1}" max="#{index + 1}" width="#{number_text(column.width)}" customWidth="1"/>)
267
+ %(<col min="#{index + 1}" max="#{index + 1}" width="#{number_text(width)}" customWidth="1"/>)
226
268
  end
227
269
  cols.empty? ? "" : "<cols>#{cols.join}</cols>"
228
270
  end
229
271
 
272
+ def merge_cells
273
+ return "" if @merges.empty?
274
+
275
+ %(<mergeCells count="#{@merges.size}">#{@merges.map { %(<mergeCell ref="#{_1}"/>) }.join}</mergeCells>)
276
+ end
277
+
278
+ # A row of any sheet, cell by cell: the type of each value is its column's, or its class's in a free sheet,
279
+ # a formula's whatever the column; a styled nil is a styled empty cell.
280
+ def any_row(values, styles)
281
+ mark = @buffer.bytesize
282
+ number = (@row_number + 1).to_s
283
+ @buffer << '<row r="' << number << '">'
284
+ count = [ values.size, styles&.size || 0 ].max
285
+ index = 0
286
+ while index < count
287
+ value = values[index]
288
+ style = styles&.[](index)
289
+ if value.nil?
290
+ @buffer << '<c r="' << letter(index) << number << '" s="' << xf(style, nil).to_s << '"/>' if style
291
+ else
292
+ any_cell(value, index, number, style)
293
+ end
294
+ index += 1
295
+ end
296
+ @buffer << "</row>"
297
+ @row_number += 1
298
+ flush if @buffer.bytesize >= BUFFER_SIZE
299
+ rescue StandardError
300
+ rollback(mark)
301
+ raise
302
+ end
303
+
304
+ def any_cell(value, index, number, style)
305
+ type = type_of(value, index)
306
+ code = style&.format&.code || default_format(type, index)
307
+ @buffer << '<c r="' << letter(index) << number << '"'
308
+ @buffer << ' s="' << xf(style, code).to_s << '"' if style || code
309
+ case type
310
+ when :formula
311
+ @formulas = true
312
+ @buffer << "><f>" << XmlText.escape(value.text) << "</f></c>"
313
+ when :string
314
+ @buffer << ' t="inlineStr"><is><t xml:space="preserve">'
315
+ value_text(value, :string, index)
316
+ @buffer << "</t></is></c>"
317
+ when :boolean then @buffer << ' t="b"><v>' << (value ? "1" : "0") << "</v></c>"
318
+ else
319
+ @buffer << "><v>"
320
+ value_text(value, type, index)
321
+ @buffer << "</v></c>"
322
+ end
323
+ end
324
+
325
+ # The type a value is written as: a formula, its column's type, or in a free sheet its class's.
326
+ def type_of(value, index)
327
+ return :formula if value.is_a?(Formula)
328
+ return @types[index] if @types
329
+
330
+ case value
331
+ when String then :string
332
+ when Integer then :integer
333
+ when BigDecimal then :decimal
334
+ when Time then :datetime
335
+ when Date then :date
336
+ else :boolean
337
+ end
338
+ end
339
+
340
+ # The number format of a cell without a style that gives one: its column's, or a date's or time's.
341
+ def default_format(type, index)
342
+ return format_of(@columns[index]) if @columns && type != :formula
343
+
344
+ case type
345
+ when :date then DATE_FORMAT
346
+ when :datetime then DATETIME_FORMAT
347
+ end
348
+ end
349
+
350
+ def letter(index)
351
+ @letters_cache[index] ||= ColumnLetter.from_index(index).freeze
352
+ end
353
+
230
354
  def number_text(number)
231
355
  number.is_a?(Integer) ? number.to_s : Float(number).to_s
232
356
  end
233
357
 
358
+ # The cellXfs index of a Style with a number format code (either may be nil), registered at its first use; a
359
+ # Style that sets only the number format shares the index of that format.
360
+ def xf(style, code)
361
+ return style(code) if style.nil? || style.plain?
362
+
363
+ @styles[[ style, code ]] ||= begin
364
+ @cell_formats << [ code ? number_format(code) : 0, font(style), fill(style), border(style), alignment(style) ]
365
+ @cell_formats.size
366
+ end
367
+ end
368
+
369
+ def font(style)
370
+ key = [ style.bold || nil, style.italic || nil, style.size, style.color ]
371
+ @fonts[key] ||= @fonts.size
372
+ end
373
+
374
+ def fill(style)
375
+ @fills[style.fill] ||= @fills.size
376
+ end
377
+
378
+ def border(style)
379
+ @borders[style.border] ||= @borders.size
380
+ end
381
+
382
+ def alignment(style)
383
+ attributes = []
384
+ attributes << %(horizontal="#{style.align}") if style.align
385
+ attributes << %(vertical="#{style.valign}") if style.valign
386
+ attributes << 'wrapText="1"' if style.wrap
387
+ attributes.empty? ? nil : "<alignment #{attributes.join(" ")}/>"
388
+ end
389
+
234
390
  def write_header(columns)
235
391
  style = style_attribute(HEADER_STYLE)
236
392
  @buffer << '<row r="1">'
@@ -243,7 +399,11 @@ module Calcsheet
243
399
  end
244
400
 
245
401
  def cell(value, index)
246
- case @types[index]
402
+ value_text(value, @types[index], index)
403
+ end
404
+
405
+ def value_text(value, type, index)
406
+ case type
247
407
  when :string
248
408
  check_length(value, index) if value.bytesize > Grid::MAX_CELL_CHARACTERS
249
409
  @buffer << XmlText.escape(value, ooxml: true)
@@ -284,7 +444,7 @@ module Calcsheet
284
444
  # @raise [WriteError] at the row being written
285
445
  def refuse(reason, index)
286
446
  row = @row_number + 1
287
- raise WriteError.at(reason, sheet: @name, row: row, column_index: index, header: @columns[index].header)
447
+ raise WriteError.at(reason, sheet: @name, row: row, column_index: index, header: @columns&.[](index)&.header)
288
448
  end
289
449
 
290
450
  # The characters of +text+ as Excel counts them: one beyond the Basic Multilingual Plane counts twice.
@@ -296,29 +456,58 @@ module Calcsheet
296
456
 
297
457
  def styles_xml
298
458
  formats = @number_formats.map { |code, id| %(<numFmt numFmtId="#{id}" formatCode="#{XmlText.escape(code)}"/>) }
299
- xfs = [ [ 0, 0 ], *@cell_formats ].map do |format, font|
300
- attributes = %(numFmtId="#{format}" fontId="#{font}" fillId="0" borderId="0" xfId="0")
301
- attributes += ' applyNumberFormat="1"' unless format.zero?
302
- attributes += ' applyFont="1"' unless font.zero?
303
- "<xf #{attributes}/>"
304
- end
305
- font = '<sz val="11"/><name val="Calibri"/><family val="2"/>'
459
+ xfs = [ [ 0, 0, 0, 0, nil ], *@cell_formats ].map { |format, font, fill, border, alignment| xf_xml(format, font, fill, border, alignment) }
306
460
  xml = +%(#{DECLARATION}<styleSheet xmlns="#{MAIN_NS}">)
307
461
  xml << %(<numFmts count="#{formats.size}">#{formats.join}</numFmts>) unless formats.empty?
308
- xml << %(<fonts count="2"><font>#{font}</font><font><b/>#{font}</font></fonts>)
309
- xml << '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
310
- xml << '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
462
+ xml << %(<fonts count="#{@fonts.size}">#{@fonts.keys.map { font_xml(*_1) }.join}</fonts>)
463
+ xml << %(<fills count="#{@fills.size}">#{@fills.keys.map { fill_xml(_1) }.join}</fills>)
464
+ xml << %(<borders count="#{@borders.size}">#{@borders.keys.map { border_xml(_1) }.join}</borders>)
311
465
  xml << '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
312
466
  xml << %(<cellXfs count="#{xfs.size}">#{xfs.join}</cellXfs>)
313
467
  xml << '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>'
314
468
  end
315
469
 
470
+ def xf_xml(format, font, fill, border, alignment)
471
+ attributes = %(numFmtId="#{format}" fontId="#{font}" fillId="#{fill}" borderId="#{border}" xfId="0")
472
+ attributes += ' applyNumberFormat="1"' unless format.zero?
473
+ attributes += ' applyFont="1"' unless font.zero?
474
+ attributes += ' applyFill="1"' unless fill.zero?
475
+ attributes += ' applyBorder="1"' unless border.zero?
476
+ alignment ? %(<xf #{attributes} applyAlignment="1">#{alignment}</xf>) : "<xf #{attributes}/>"
477
+ end
478
+
479
+ def font_xml(bold, italic, size, color)
480
+ xml = +"<font>"
481
+ xml << "<b/>" if bold
482
+ xml << "<i/>" if italic
483
+ xml << %(<sz val="#{number_text(size || 11)}"/>)
484
+ xml << %(<color rgb="FF#{color}"/>) if color
485
+ xml << '<name val="Calibri"/><family val="2"/></font>'
486
+ end
487
+
488
+ def fill_xml(key)
489
+ case key
490
+ when nil then '<fill><patternFill patternType="none"/></fill>'
491
+ when :gray125 then '<fill><patternFill patternType="gray125"/></fill>'
492
+ else %(<fill><patternFill patternType="solid"><fgColor rgb="FF#{key}"/><bgColor indexed="64"/></patternFill></fill>)
493
+ end
494
+ end
495
+
496
+ def border_xml(border)
497
+ edges = %i[left right top bottom].map do |edge|
498
+ next "<#{edge}/>" unless border&.edges&.include?(edge)
499
+
500
+ %(<#{edge} style="#{border.style}"><color rgb="FF#{border.color}"/></#{edge}>)
501
+ end
502
+ "<border>#{edges.join}<diagonal/></border>"
503
+ end
504
+
316
505
  def workbook_xml
317
506
  sheets = @sheet_names.each_with_index.map do |name, index|
318
507
  %(<sheet name="#{XmlText.escape(name)}" sheetId="#{index + 1}" r:id="rId#{index + 1}"/>)
319
508
  end
320
509
  %(#{DECLARATION}<workbook xmlns="#{MAIN_NS}" xmlns:r="#{REL_NS}"><bookViews><workbookView activeTab="0"/></bookViews>) +
321
- "<sheets>#{sheets.join}</sheets></workbook>"
510
+ "<sheets>#{sheets.join}</sheets>#{'<calcPr fullCalcOnLoad="1"/>' if @formulas}</workbook>"
322
511
  end
323
512
 
324
513
  def workbook_relationships_xml
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tempfile"
3
4
  require_relative "../grid"
4
5
 
5
6
  module Calcsheet
@@ -36,10 +37,28 @@ module Calcsheet
36
37
  end
37
38
 
38
39
  def flush
39
- @entry << @buffer
40
+ @deferred ? @deferred.write(@buffer) : @entry << @buffer
40
41
  @buffer.clear
41
42
  end
42
43
 
44
+ # From now on the flushed rows go to a temporary file (+auto_width+: what precedes them is known only after
45
+ # them), until #replay_deferred. A Tempfile: one left behind by a book whose block raised is deleted when it is
46
+ # collected.
47
+ def defer_rows
48
+ @deferred = Tempfile.new("calcsheet-rows", binmode: true)
49
+ end
50
+
51
+ # Writes the deferred rows to the entry and deletes the file.
52
+ def replay_deferred
53
+ file = @deferred
54
+ @deferred = nil
55
+ file.rewind
56
+ chunk = String.new(capacity: BUFFER_SIZE)
57
+ @entry << chunk while file.read(BUFFER_SIZE, chunk)
58
+ ensure
59
+ file&.close!
60
+ end
61
+
43
62
  # The sheet of a book whose block wrote none.
44
63
  def empty_book_sheet
45
64
  start_sheet(EMPTY_BOOK_SHEET, [], header: false)
data/lib/calcsheet.rb CHANGED
@@ -28,13 +28,19 @@ require_relative "calcsheet/readers/xlsx/workbook_meta"
28
28
  require_relative "calcsheet/readers/xlsx/shared_strings"
29
29
  require_relative "calcsheet/readers/xlsx_reader"
30
30
  require_relative "calcsheet/readers/ods_reader"
31
+ require_relative "calcsheet/native_backends"
31
32
  require_relative "calcsheet/column"
33
+ require_relative "calcsheet/formula"
34
+ require_relative "calcsheet/cell_range"
35
+ require_relative "calcsheet/display_format"
36
+ require_relative "calcsheet/style"
32
37
  require_relative "calcsheet/sheet_names"
33
38
  require_relative "calcsheet/write_sheet"
34
39
  require_relative "calcsheet/write_book"
35
40
  require_relative "calcsheet/stream_body"
36
41
  require_relative "calcsheet/writers/csv_writer"
37
42
  require_relative "calcsheet/writers/xml_text"
43
+ require_relative "calcsheet/writers/open_formula"
38
44
  require_relative "calcsheet/writers/xlsx_writer"
39
45
  require_relative "calcsheet/writers/ods_writer"
40
46
 
@@ -45,14 +51,22 @@ require_relative "calcsheet/writers/ods_writer"
45
51
  module Calcsheet
46
52
  # The formats Calcsheet.write infers from the extension of a path.
47
53
  WRITE_EXTENSIONS = { ".csv" => :csv, ".xlsx" => :xlsx, ".ods" => :ods }.freeze
54
+ # @api private
55
+ # calcsheet's own reader backends by format: the initial Calcsheet.backends, and the readers calcsheet-native
56
+ # replaces (NativeBackends).
57
+ RUBY_BACKENDS = { csv: Readers::CsvReader, xlsx: Readers::XlsxReader, ods: Readers::OdsReader }.freeze
48
58
 
49
59
  class << self
50
60
  # The reader backends by format (see Reading for their contract). A mutable registry: an application may add or
51
61
  # replace a backend before it opens files (+Calcsheet.backends[:tsv] = MyReader+). Detection only ever answers
52
62
  # +:csv+, +:xlsx+ or +:ods+, so a backend under another key is used only with an explicit +format:+.
63
+ #
64
+ # The registry holds the Ruby backends: calcsheet-native, when it is installed and compatible, reads in place of
65
+ # calcsheet's own readers under +backend: :auto+ (the default) and +:native+ (see Book#backend). A backend an
66
+ # application registered is never replaced (+backend: :native+ refuses its format).
53
67
  # @return [Hash{Symbol => Class}]
54
68
  def backends
55
- @backends ||= { csv: Readers::CsvReader, xlsx: Readers::XlsxReader, ods: Readers::OdsReader }
69
+ @backends ||= RUBY_BACKENDS.dup
56
70
  end
57
71
 
58
72
  # Opens a spreadsheet for reading. With a block, yields the book, closes it afterwards and returns the
@@ -65,7 +79,9 @@ module Calcsheet
65
79
  # caller is never closed
66
80
  # @param format [Symbol, String, nil] a key of Calcsheet.backends, which skips detection; nil detects +:csv+,
67
81
  # +:xlsx+ or +:ods+ from the content (the extension only breaks a tie)
68
- # @param options [Hash] Options overrides for this book (any key of Configuration::DEFAULTS)
82
+ # @param options [Hash] Options overrides for this book (any key of Configuration::DEFAULTS); +backend:+ chooses
83
+ # the reader: +:auto+ calcsheet-native when it is installed and compatible, else the Ruby backend; +:ruby+ the
84
+ # backend of Calcsheet.backends; +:native+ calcsheet-native or BackendUnavailableError
69
85
  # @yieldparam book [Book]
70
86
  # @return [Object, Book] the block's value with a block; the open book without one
71
87
  # @raise [ArgumentError] an unknown or invalid option, a source that is neither a path nor an IO, or a +format:+
@@ -75,6 +91,8 @@ module Calcsheet
75
91
  # it is read instead)
76
92
  # @raise [UnsupportedFormatError] an XLS or encrypted (OLE2) file, a zip that is neither XLSX nor ODS, an encrypted
77
93
  # or unsupported zip entry, or a +format:+ without a backend
94
+ # @raise [BackendUnavailableError] +backend: :native+ and calcsheet-native cannot read the book (not installed, an
95
+ # unsupported protocol, a format it does not read, or an application's own backend for the format)
78
96
  # @raise [MalformedFileError] a damaged zip archive or workbook part
79
97
  # @raise [ZipBombError] the archive has more entries than +max_zip_entries+, or reading its workbook parts
80
98
  # exceeds a zip limit
@@ -82,12 +100,12 @@ module Calcsheet
82
100
  # raises it, at the first Book#sheets or read)
83
101
  def open(source, format: nil, **options)
84
102
  options = config.options(**options)
85
- backend_class = format && backend_for(format)
86
- input = Source.open(source, options, needs_seek: backend_class.nil? || backend_class.needs_seek?)
103
+ selected = format && select_backend(format, options.backend)
104
+ input = Source.open(source, options, needs_seek: selected.nil? || selected.first.needs_seek?)
87
105
  begin
88
106
  format = format.nil? ? FormatDetector.detect(input.io, path: input.path, max_zip_entries: options.max_zip_entries) : format.to_sym
89
- backend_class ||= backend_for(format)
90
- book = Book.new(backend_class.new(input, options), format: format, options: options, source: input)
107
+ backend_class, backend = selected || select_backend(format, options.backend)
108
+ book = Book.new(backend_class.new(input, options), format: format, options: options, source: input, backend: backend)
91
109
  # Exception, not StandardError: an Interrupt or a timeout during detection must also close the Source,
92
110
  # which deletes the Tempfile copy of a non-seekable stream (as Source#initialize does).
93
111
  rescue Exception # rubocop:disable Lint/RescueException
@@ -287,9 +305,14 @@ module Calcsheet
287
305
  raise ArgumentError, "format: expected a Symbol or a String, got #{format.inspect}"
288
306
  end
289
307
 
290
- def backend_for(format)
308
+ # @param mode [Symbol] the +backend:+ option
309
+ # @return [Array(Class, Symbol)] the backend class of +format+ and +:ruby+ or +:native+ (Book#backend)
310
+ def select_backend(format, mode)
291
311
  check_format_type(format)
292
- backends.fetch(format.to_sym) { raise UnsupportedFormatError, "no reader for format #{format.to_sym.inspect}" }
312
+ format = format.to_sym
313
+ backend_class = backends.fetch(format) { raise UnsupportedFormatError, "no reader for format #{format.inspect}" }
314
+ native = NativeBackends.backend_for(format, mode) unless mode == :ruby
315
+ native ? [ native, :native ] : [ backend_class, :ruby ]
293
316
  end
294
317
  end
295
318
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calcsheet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Demetra Opinioni.net Srl
@@ -101,19 +101,23 @@ files:
101
101
  - lib/calcsheet.rb
102
102
  - lib/calcsheet/book.rb
103
103
  - lib/calcsheet/cast.rb
104
+ - lib/calcsheet/cell_range.rb
104
105
  - lib/calcsheet/column.rb
105
106
  - lib/calcsheet/column_letter.rb
106
107
  - lib/calcsheet/configuration.rb
107
108
  - lib/calcsheet/decimal_text.rb
109
+ - lib/calcsheet/display_format.rb
108
110
  - lib/calcsheet/entry_name.rb
109
111
  - lib/calcsheet/errors.rb
110
112
  - lib/calcsheet/excel_serial.rb
111
113
  - lib/calcsheet/format_detector.rb
114
+ - lib/calcsheet/formula.rb
112
115
  - lib/calcsheet/formula_protection.rb
113
116
  - lib/calcsheet/grid.rb
114
117
  - lib/calcsheet/header_keys.rb
115
118
  - lib/calcsheet/limited_inflater.rb
116
119
  - lib/calcsheet/merge_check.rb
120
+ - lib/calcsheet/native_backends.rb
117
121
  - lib/calcsheet/odf.rb
118
122
  - lib/calcsheet/option_validation.rb
119
123
  - lib/calcsheet/options.rb
@@ -122,6 +126,7 @@ files:
122
126
  - lib/calcsheet/readers/csv_dialect.rb
123
127
  - lib/calcsheet/readers/csv_input.rb
124
128
  - lib/calcsheet/readers/csv_reader.rb
129
+ - lib/calcsheet/readers/csv_records.rb
125
130
  - lib/calcsheet/readers/ods/cell_formats.rb
126
131
  - lib/calcsheet/readers/ods/content_parser.rb
127
132
  - lib/calcsheet/readers/ods/count.rb
@@ -149,13 +154,16 @@ files:
149
154
  - lib/calcsheet/readers/xlsx_reader.rb
150
155
  - lib/calcsheet/reading.rb
151
156
  - lib/calcsheet/row.rb
157
+ - lib/calcsheet/row_builder.rb
152
158
  - lib/calcsheet/sample.rb
153
159
  - lib/calcsheet/schema.rb
154
160
  - lib/calcsheet/sheet_info.rb
161
+ - lib/calcsheet/sheet_list.rb
155
162
  - lib/calcsheet/sheet_names.rb
156
163
  - lib/calcsheet/sheet_selection.rb
157
164
  - lib/calcsheet/source.rb
158
165
  - lib/calcsheet/stream_body.rb
166
+ - lib/calcsheet/style.rb
159
167
  - lib/calcsheet/time_zone.rb
160
168
  - lib/calcsheet/untrusted.rb
161
169
  - lib/calcsheet/version.rb
@@ -163,6 +171,7 @@ files:
163
171
  - lib/calcsheet/write_sheet.rb
164
172
  - lib/calcsheet/writers/csv_writer.rb
165
173
  - lib/calcsheet/writers/ods_writer.rb
174
+ - lib/calcsheet/writers/open_formula.rb
166
175
  - lib/calcsheet/writers/xlsx_writer.rb
167
176
  - lib/calcsheet/writers/xml_text.rb
168
177
  - lib/calcsheet/writers/zip_buffered.rb
@@ -194,7 +203,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
194
203
  - !ruby/object:Gem::Version
195
204
  version: '0'
196
205
  requirements: []
197
- rubygems_version: 3.6.9
206
+ rubygems_version: 4.0.16
198
207
  specification_version: 4
199
208
  summary: Streaming CSV, XLSX and ODS reader and writer.
200
209
  test_files: []