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
@@ -3,15 +3,19 @@
3
3
  require "bigdecimal"
4
4
  require "date"
5
5
  require_relative "cast"
6
+ require_relative "cell_range"
6
7
  require_relative "column"
8
+ require_relative "decimal_text"
7
9
  require_relative "errors"
10
+ require_relative "formula"
11
+ require_relative "grid"
8
12
  require_relative "time_zone"
9
13
 
10
14
  module Calcsheet
11
- # A sheet being written, yielded by WriteBook#sheet. Each row is checked against the column types
12
- # before the backend sees it, so a rejected row (WriteError) writes nothing and the sheet stays usable. When the
13
- # sheet reaches the row limit (header included) it continues in a sheet named "Base (2)", "Base (3)"..., repeating
14
- # the header.
15
+ # A sheet being written, yielded by WriteBook#sheet. Each row is checked against the column types (in a free
16
+ # sheet, each value against the types a value can have) before the backend sees it, so a rejected row (WriteError)
17
+ # writes nothing and the sheet stays usable. When the sheet reaches the row limit (header included) it continues in
18
+ # a sheet named "Base (2)", "Base (3)"..., repeating the header.
15
19
  class WriteSheet
16
20
  # What each column type accepts, for the message of a refused value.
17
21
  EXPECTED = {
@@ -29,6 +33,15 @@ module Calcsheet
29
33
  NO_TEXT_LIMIT = 1 << 62
30
34
  # The unknown keys of a row a message lists.
31
35
  SHOWN_KEYS = 10
36
+ # What a value of a free sheet may be, for the message of a refused value.
37
+ FREE_EXPECTED = "a String, an Integer, a finite BigDecimal, a Date, a Time, true, false, a Calcsheet::Formula or nil"
38
+ # The font size of the default style, which #measure compares a style's with.
39
+ DEFAULT_SIZE = 11
40
+ # The characters of the cells of each type #measure counts without a text, and the margin it adds.
41
+ DATE_CHARACTERS = 10
42
+ DATETIME_CHARACTERS = 19
43
+ BOOLEAN_CHARACTERS = 5
44
+ WIDTH_MARGIN = 2
32
45
 
33
46
  # @return [String] the name of the sheet rows are written to, a continuation after an overflow
34
47
  attr_reader :name
@@ -40,16 +53,29 @@ module Calcsheet
40
53
  # @param limit [Integer, nil] rows per sheet, header included; nil for no limit
41
54
  # @param zone [#local, nil] the time_zone that converts written times; nil keeps their own wall clock
42
55
  # @param max_cell_bytes [Integer, nil] the longest text a cell may hold, as the readers bound it; nil for none
43
- # @raise [ArgumentError] on invalid columns, name or limit
56
+ # @param styles [Hash{Symbol => Style}] the styles of the book, by name (WriteBook#style)
57
+ # @param widths [Array<Numeric, nil>, nil] a free sheet's column widths
58
+ # @param auto_width [Boolean] measure the columns without a width (see WriteBook#sheet)
59
+ # @raise [ArgumentError] on invalid columns, name, limit or widths
44
60
  # @raise [WriteError] for a written header longer than +max_cell_bytes+, or one the backend refuses
45
- def initialize(writer, names, name, columns, header:, limit:, zone:, max_cell_bytes: nil)
46
- raise ArgumentError, "columns: must be a non-empty Array" unless columns.is_a?(Array) && !columns.empty?
61
+ def initialize(writer, names, name, columns, header:, limit:, zone:, max_cell_bytes: nil, styles: {}, widths: nil,
62
+ auto_width: false)
63
+ if columns.nil?
64
+ raise ArgumentError, "header: a sheet without columns: has no header" if header
65
+ elsif !columns.is_a?(Array) || columns.empty?
66
+ raise ArgumentError, "columns: must be a non-empty Array, or nil for a free sheet"
67
+ end
68
+ raise ArgumentError, "widths: are given by the columns' width: in a sheet with columns:" if columns && widths
69
+ raise ArgumentError, "auto_width: must be true or false, got #{auto_width.inspect}" unless auto_width == true || auto_width == false
47
70
 
48
- @columns = columns.map { Column.from(_1) }.freeze
49
- @types = @columns.map(&:type).freeze
71
+ @columns = columns&.map { Column.from(_1) }.freeze
72
+ @types = @columns&.map(&:type).freeze
73
+ @widths = checked_widths(widths)
50
74
  raise ArgumentError, "header: must be true or false, got #{header.inspect}" unless header == true || header == false
51
75
 
52
76
  @header = header
77
+ @styles = styles
78
+ @auto_width = auto_width
53
79
  if @header && limit && limit < 2
54
80
  raise ArgumentError, "max_rows_per_sheet: #{limit} leaves no room for data rows after the header"
55
81
  end
@@ -84,27 +110,82 @@ module Calcsheet
84
110
  # @raise [ArgumentError] the row is neither an Array nor a Hash, has unknown or ambiguous keys, or the sheet's
85
111
  # block has ended
86
112
  def <<(row)
113
+ add(row)
114
+ end
115
+
116
+ # Writes a row, as #<<, with styles (XLSX and ODS; CSV ignores them).
117
+ #
118
+ # In a free sheet (no columns) +row+ is an Array of any length up to the columns of the grid, and each value is
119
+ # written with the type of its class: String, Integer, BigDecimal (finite; a Float is refused, as in a +:decimal+
120
+ # column), Date, Time (the +time_zone:+ applies), true or false. A Formula is written in any sheet and column.
121
+ #
122
+ # @param row [Array, Hash] see #<<; an Array only in a free sheet
123
+ # @param style [Symbol, Array<Symbol, nil>, nil] the name of a style (WriteBook#style) for every cell of the row
124
+ # (the values given, or every column of a typed sheet), or one per cell; a style beyond the values styles an empty
125
+ # cell
126
+ # @param measure [Boolean] count the row's values in the widths +auto_width: true+ measures (false for a title
127
+ # that should not widen its column)
128
+ # @return [self]
129
+ # @raise [WriteError] see #<<; also a value in a merged cell but the first
130
+ # @raise [ArgumentError] see #<<; also an unknown style name
131
+ def add(row, style: nil, measure: true)
87
132
  raise ArgumentError, "sheet #{@name.inspect} is closed" if @closed
88
133
 
89
134
  values = check(values_of(row))
135
+ styles = styles_of(style, values.size)
136
+ check_merged(values)
90
137
  if full?
91
138
  problem = @writer.respond_to?(:row_problem) && @writer.row_problem(values)
92
139
  raise failure(problem[1], problem[0]) if problem
93
140
 
94
141
  continue
95
142
  end
96
- @writer.row(values)
143
+ styles ? @writer.row(values, styles) : @writer.row(values)
144
+ measure(values, styles) if @auto_width && measure
97
145
  @rows += 1
98
146
  self
99
147
  end
100
148
 
149
+ # Merges the cells of +reference+ (XLSX and ODS; CSV ignores it). ODS writes a merge on its first cell, so it is
150
+ # declared before that cell's row is written; the cells it covers but the first must be empty (they may be
151
+ # styled).
152
+ #
153
+ # @param reference [String] "A1:B2", rows numbered from the first of the sheet (the header included)
154
+ # @return [self]
155
+ # @raise [ArgumentError] an invalid reference, a single cell, a row already written, one beyond the rows a sheet
156
+ # holds, or a merge overlapping another
157
+ def merge(reference)
158
+ raise ArgumentError, "sheet #{@name.inspect} is closed" if @closed
159
+
160
+ range = CellRange.parse(reference)
161
+ raise ArgumentError, "merge #{reference.inspect}: a merge covers at least two cells" if range.size < 2
162
+ if range.first_row <= @rows
163
+ raise ArgumentError, "merge #{range}: row #{range.first_row} is written already; declare a merge before its first row"
164
+ end
165
+ if @limit && range.last_row > @limit
166
+ raise ArgumentError, "merge #{range}: beyond the #{@limit} rows of a sheet"
167
+ end
168
+ if (other = @merges.find { _1.overlap?(range) })
169
+ raise ArgumentError, "merge #{range} overlaps merge #{other}"
170
+ end
171
+
172
+ @merges << range
173
+ @writer.merge(range) if @writer.respond_to?(:merge)
174
+ self
175
+ end
176
+
101
177
  private
102
178
 
103
179
  # Ends the sheet in the backend. Private, like #abandon: only WriteBook#sheet calls them (through +send+), once,
104
180
  # after the block, so the block cannot end the sheet twice and corrupt the file.
105
181
  def close
106
182
  @closed = true
107
- @writer.end_sheet
183
+ end_part
184
+ end
185
+
186
+ # Ends the sheet, or the part of it before a continuation, giving the measured widths with +auto_width+.
187
+ def end_part
188
+ @auto_width ? @writer.end_sheet(measured_widths) : @writer.end_sheet
108
189
  end
109
190
 
110
191
  # Marks the sheet closed without ending it in the backend, when its block did not complete.
@@ -113,9 +194,16 @@ module Calcsheet
113
194
  end
114
195
 
115
196
  def start(name)
116
- @writer.start_sheet(name, @columns, header: @header)
197
+ if @columns.nil? || @auto_width
198
+ @writer.start_sheet(name, @columns, header: @header, widths: @widths, auto_width: @auto_width)
199
+ else
200
+ @writer.start_sheet(name, @columns, header: @header)
201
+ end
117
202
  @name = name
118
203
  @rows = @header ? 1 : 0
204
+ @merges = []
205
+ @measured = []
206
+ measure_headers if @auto_width && @header
119
207
  end
120
208
 
121
209
  def full?
@@ -123,7 +211,7 @@ module Calcsheet
123
211
  end
124
212
 
125
213
  def continue
126
- @writer.end_sheet
214
+ end_part
127
215
  name, @part = @names.claim_continuation(@base, @part + 1)
128
216
  start(name)
129
217
  end
@@ -131,10 +219,17 @@ module Calcsheet
131
219
  def values_of(row)
132
220
  case row
133
221
  when Array
134
- raise failure("#{row.size} values for #{@columns.size} columns") if row.size > @columns.size
222
+ if @columns.nil?
223
+ raise failure("#{row.size} values, more than the #{Grid::MAX_COLUMNS} columns of a sheet") if row.size > Grid::MAX_COLUMNS
224
+ elsif row.size > @columns.size
225
+ raise failure("#{row.size} values for #{@columns.size} columns")
226
+ end
135
227
 
136
228
  row
137
- when Hash then from_hash(row)
229
+ when Hash
230
+ raise ArgumentError, "a sheet without columns: takes Array rows, got a Hash" if @columns.nil?
231
+
232
+ from_hash(row)
138
233
  else raise ArgumentError, "row must be an Array or a Hash, got #{Untrusted.show(row)}"
139
234
  end
140
235
  end
@@ -167,14 +262,16 @@ module Calcsheet
167
262
  end
168
263
 
169
264
  def check(values)
170
- Array.new(@columns.size) do |index|
265
+ Array.new(@columns ? @columns.size : values.size) do |index|
171
266
  value = values[index]
172
267
  value.nil? ? nil : cell(value, index)
173
268
  end
174
269
  end
175
270
 
176
271
  def cell(value, index)
177
- type = @types[index]
272
+ return value if value.is_a?(Formula)
273
+
274
+ type = @types ? @types[index] : free_type(value, index)
178
275
  case type
179
276
  when :string
180
277
  if value.is_a?(String)
@@ -196,6 +293,111 @@ module Calcsheet
196
293
  raise failure("expected #{EXPECTED.fetch(type)} (:#{type} column), got #{Untrusted.show(value)}", index)
197
294
  end
198
295
 
296
+ # The type of a value of a free sheet: the type #cell checks it as.
297
+ def free_type(value, index)
298
+ case value
299
+ when String then :string
300
+ when Integer then :integer
301
+ when BigDecimal then :decimal
302
+ when Time then :datetime
303
+ when Date
304
+ return :date unless value.is_a?(DateTime)
305
+
306
+ raise failure("expected #{FREE_EXPECTED}, got a DateTime #{Untrusted.show(value)}: write a Time", index)
307
+ when true, false then :boolean
308
+ when Float then raise failure("a Float is not written, so that no binary rounding slips in: write a BigDecimal or an Integer, got #{value}", index)
309
+ else raise failure("expected #{FREE_EXPECTED}, got #{Untrusted.show(value)}", index)
310
+ end
311
+ end
312
+
313
+ # The Styles of a row by column index, nil without any.
314
+ # @raise [ArgumentError] an unknown name or another value
315
+ def styles_of(style, size)
316
+ case style
317
+ when nil then nil
318
+ when Symbol then Array.new(@columns ? @columns.size : size, lookup_style(style))
319
+ when Array
320
+ if @columns ? style.size > @columns.size : style.size > Grid::MAX_COLUMNS
321
+ raise ArgumentError, "style: #{style.size} styles, more than the #{@columns ? @columns.size : Grid::MAX_COLUMNS} columns"
322
+ end
323
+
324
+ style.map { _1 && lookup_style(_1) }
325
+ else raise ArgumentError, "style: expected a style name, an Array of them or nil, got #{Untrusted.show(style)}"
326
+ end
327
+ end
328
+
329
+ def lookup_style(name)
330
+ @styles.fetch(name) { raise ArgumentError, "style #{Untrusted.show(name)} is not defined (WriteBook#style)" }
331
+ end
332
+
333
+ # The cells a merge covers but its first must stay empty: their value would be hidden.
334
+ def check_merged(values)
335
+ return if @merges.empty?
336
+
337
+ row = @rows + 1
338
+ @merges.each do |range|
339
+ next unless range.first_row <= row && row <= range.last_row
340
+
341
+ range.first_column.upto([ range.last_column, values.size - 1 ].min) do |column|
342
+ next if values[column].nil? || (row == range.first_row && column == range.first_column)
343
+
344
+ raise failure("the cell is covered by merge #{range}, which shows only its first cell", column)
345
+ end
346
+ end
347
+ end
348
+
349
+ # A free sheet's widths, checked as a column's width:.
350
+ def checked_widths(widths)
351
+ return nil if widths.nil?
352
+ raise ArgumentError, "widths: must be an Array, got #{widths.inspect}" unless widths.is_a?(Array)
353
+ raise ArgumentError, "widths: more than the #{Grid::MAX_COLUMNS} columns of a sheet" if widths.size > Grid::MAX_COLUMNS
354
+
355
+ widths.map { Column.checked_width(_1) }.freeze
356
+ end
357
+
358
+ # The widths given (a column's width:, or widths:), else those measured, by column index.
359
+ def measured_widths
360
+ count = [ @columns&.size || 0, @widths&.size || 0, @measured.size ].max
361
+ Array.new(count) do |index|
362
+ given = @columns ? @columns[index]&.width : @widths&.[](index)
363
+ given || @measured[index]&.clamp(1, Column::MAX_WIDTH)
364
+ end
365
+ end
366
+
367
+ def measure_headers
368
+ @columns.each_with_index { |column, index| widen(index, column.header.length * 1.1) }
369
+ end
370
+
371
+ # Widens the columns to the characters of the values, scaled by their style's font: a rough measure, as Excel's
372
+ # own autofit is, of the text the cell shows. Formulas are not measured: their result is not known, nor the first
373
+ # cell of a merge across columns, whose text spans them.
374
+ def measure(values, styles)
375
+ row = @rows + 1
376
+ spanning = @merges.select { _1.first_row == row && _1.last_column > _1.first_column }.map(&:first_column)
377
+ values.each_with_index do |value, index|
378
+ next if spanning.include?(index)
379
+
380
+ characters =
381
+ case value
382
+ when nil, Formula then next
383
+ when String then value.each_line.map { _1.chomp.length }.max || 0
384
+ when Integer then value.to_s.length
385
+ when BigDecimal then DecimalText.plain(value).length
386
+ when Time then DATETIME_CHARACTERS
387
+ when Date then DATE_CHARACTERS
388
+ else BOOLEAN_CHARACTERS
389
+ end
390
+ style = styles&.[](index)
391
+ factor = (style&.size || DEFAULT_SIZE).fdiv(DEFAULT_SIZE) * (style&.bold ? 1.1 : 1)
392
+ widen(index, characters * factor)
393
+ end
394
+ end
395
+
396
+ def widen(index, characters)
397
+ width = (characters + WIDTH_MARGIN).ceil
398
+ @measured[index] = width if @measured[index].nil? || @measured[index] < width
399
+ end
400
+
199
401
  # A number whose written text (the full digits of an Integer, the plain text of a BigDecimal) is longer than
200
402
  # Cast::MAX_NUMBER_BYTES would make the file unreadable: the readers refuse longer numbers. The length is
201
403
  # computed without the text, which for BigDecimal("1e500000000") would take gigabytes. An Integer comes here only
@@ -276,7 +478,7 @@ module Calcsheet
276
478
  # A WriteError located where the row would be written: the next row, or a continuation's first data row.
277
479
  def failure(reason, index = nil)
278
480
  sheet, row = full? ? [ @names.free_continuation(@base, @part + 1).first, @header ? 2 : 1 ] : [ @name, @rows + 1 ]
279
- WriteError.at(reason, sheet: sheet, row: row, column_index: index, header: index && @columns[index].header)
481
+ WriteError.at(reason, sheet: sheet, row: row, column_index: index, header: index && @columns&.[](index)&.header)
280
482
  end
281
483
  end
282
484
  end
@@ -6,6 +6,7 @@ require "date"
6
6
  require "stringio"
7
7
  require_relative "../decimal_text"
8
8
  require_relative "../errors"
9
+ require_relative "../formula"
9
10
  require_relative "../formula_protection"
10
11
 
11
12
  module Calcsheet
@@ -16,7 +17,8 @@ module Calcsheet
16
17
  module Writers
17
18
  # CSV writer backend, see the contract in WriteBook. A CSV file holds one sheet and has no row limit.
18
19
  # Text cells and headers starting with a formula character get a leading "'" unless their column disables
19
- # +formula_protection+ (headers of every type, cells of :string columns). Output is UTF-8.
20
+ # +formula_protection+ (headers of every type, cells of :string columns); every text of a free sheet (no columns)
21
+ # is protected. A Formula is written as its text with "=", styles, merges and widths are ignored. Output is UTF-8.
20
22
  #
21
23
  # @api private
22
24
  class CsvWriter
@@ -67,24 +69,24 @@ module Calcsheet
67
69
 
68
70
  # Writes the BOM, if any, and the header row.
69
71
  # @raise [WriteError] for a second sheet
70
- def start_sheet(name, columns, header:)
72
+ def start_sheet(name, columns, header:, widths: nil, auto_width: false) # rubocop:disable Lint/UnusedMethodArgument
71
73
  raise WriteError.new("a CSV file holds a single sheet: cannot start sheet '#{name}'", sheet: name) if @started
72
74
 
73
75
  @started = true
74
- @protected = columns.map { |column| column.type == :string && column.formula_protection }
76
+ @protected = columns&.map { |column| column.type == :string && column.formula_protection }
75
77
  @io.write(BOM) if @dialect[:bom]
76
78
  emit(columns.map { header_text(_1) }) if header
77
79
  end
78
80
 
79
81
  # Writes one record: each value as its text (see #text), quoted by the stdlib CSV where needed.
80
- def row(values)
82
+ def row(values, _styles = nil)
81
83
  emit(values.each_with_index.map { |value, index| text(value, index) })
82
84
  end
83
85
 
84
86
  # @param text [String] a text of the column at +index+ (WriteBook contract)
85
87
  # @return [Integer] the bytes written for it, the "'" of formula protection included
86
88
  def text_bytesize(text, index)
87
- @protected[index] ? FormulaProtection.protect(text).bytesize : text.bytesize
89
+ protected?(index) ? FormulaProtection.protect(text).bytesize : text.bytesize
88
90
  end
89
91
 
90
92
  # @param column [Column] a column of a sheet about to start (WriteBook contract)
@@ -93,8 +95,12 @@ module Calcsheet
93
95
  header_text(column).bytesize
94
96
  end
95
97
 
98
+ # A CSV file has no merged cells.
99
+ def merge(_range)
100
+ end
101
+
96
102
  # Nothing ends a CSV sheet.
97
- def end_sheet
103
+ def end_sheet(_widths = nil)
98
104
  end
99
105
 
100
106
  # Nothing completes a CSV file.
@@ -119,7 +125,8 @@ module Calcsheet
119
125
  def text(value, index)
120
126
  case value
121
127
  when nil then nil
122
- when String then @protected[index] ? FormulaProtection.protect(value) : value
128
+ when String then protected?(index) ? FormulaProtection.protect(value) : value
129
+ when Formula then value.to_s
123
130
  when Integer then value.to_s
124
131
  when BigDecimal then decimal(value)
125
132
  when Time then value.strftime(@dialect[:datetime_format])
@@ -128,6 +135,10 @@ module Calcsheet
128
135
  end
129
136
  end
130
137
 
138
+ def protected?(index)
139
+ @protected.nil? || @protected[index]
140
+ end
141
+
131
142
  # The exact value without exponent, its integral ".0" dropped.
132
143
  def decimal(value)
133
144
  text = DecimalText.plain(value)