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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +124 -9
- data/lib/calcsheet/book.rb +25 -9
- data/lib/calcsheet/cell_range.rb +54 -0
- data/lib/calcsheet/configuration.rb +10 -0
- data/lib/calcsheet/display_format.rb +75 -0
- data/lib/calcsheet/errors.rb +5 -0
- data/lib/calcsheet/formula.rb +40 -0
- data/lib/calcsheet/limited_inflater.rb +16 -0
- data/lib/calcsheet/native_backends.rb +139 -0
- data/lib/calcsheet/option_validation.rb +4 -1
- data/lib/calcsheet/raw_row.rb +1 -1
- data/lib/calcsheet/readers/csv_reader.rb +43 -20
- data/lib/calcsheet/readers/csv_records.rb +193 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
- data/lib/calcsheet/readers/ods/scanner.rb +33 -6
- data/lib/calcsheet/readers/ods_reader.rb +16 -1
- data/lib/calcsheet/readers/sax_sheet.rb +6 -48
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
- data/lib/calcsheet/reading.rb +29 -7
- data/lib/calcsheet/row.rb +2 -2
- data/lib/calcsheet/row_builder.rb +235 -0
- data/lib/calcsheet/sheet_list.rb +25 -0
- data/lib/calcsheet/sheet_selection.rb +3 -2
- data/lib/calcsheet/style.rb +114 -0
- data/lib/calcsheet/version.rb +1 -1
- data/lib/calcsheet/write_book.rb +49 -17
- data/lib/calcsheet/write_sheet.rb +220 -18
- data/lib/calcsheet/writers/csv_writer.rb +18 -7
- data/lib/calcsheet/writers/ods_writer.rb +249 -16
- data/lib/calcsheet/writers/open_formula.rb +67 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
- data/lib/calcsheet/writers/zip_buffered.rb +20 -1
- data/lib/calcsheet.rb +31 -8
- 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)
|
|
13
|
-
# sheet reaches the row limit (header included) it continues in
|
|
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
|
-
# @
|
|
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
|
-
|
|
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
|
|
49
|
-
@types = @columns
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|