calcsheet 0.1.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE.txt +21 -0
- data/README.md +921 -0
- data/lib/calcsheet/book.rb +164 -0
- data/lib/calcsheet/cast.rb +578 -0
- data/lib/calcsheet/column.rb +117 -0
- data/lib/calcsheet/column_letter.rb +34 -0
- data/lib/calcsheet/configuration.rb +186 -0
- data/lib/calcsheet/decimal_text.rb +19 -0
- data/lib/calcsheet/entry_name.rb +28 -0
- data/lib/calcsheet/errors.rb +186 -0
- data/lib/calcsheet/excel_serial.rb +65 -0
- data/lib/calcsheet/format_detector.rb +109 -0
- data/lib/calcsheet/formula_protection.rb +31 -0
- data/lib/calcsheet/grid.rb +20 -0
- data/lib/calcsheet/header_keys.rb +66 -0
- data/lib/calcsheet/limited_inflater.rb +326 -0
- data/lib/calcsheet/merge_check.rb +41 -0
- data/lib/calcsheet/odf.rb +45 -0
- data/lib/calcsheet/option_validation.rb +160 -0
- data/lib/calcsheet/options.rb +10 -0
- data/lib/calcsheet/raw_row.rb +18 -0
- data/lib/calcsheet/raw_types.rb +15 -0
- data/lib/calcsheet/readers/csv_dialect.rb +264 -0
- data/lib/calcsheet/readers/csv_input.rb +128 -0
- data/lib/calcsheet/readers/csv_reader.rb +239 -0
- data/lib/calcsheet/readers/ods/cell_formats.rb +115 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +594 -0
- data/lib/calcsheet/readers/ods/count.rb +30 -0
- data/lib/calcsheet/readers/ods/cursor.rb +121 -0
- data/lib/calcsheet/readers/ods/patterns.rb +70 -0
- data/lib/calcsheet/readers/ods/row_seek.rb +157 -0
- data/lib/calcsheet/readers/ods/scanner.rb +302 -0
- data/lib/calcsheet/readers/ods/style_scan.rb +108 -0
- data/lib/calcsheet/readers/ods/tag_text.rb +76 -0
- data/lib/calcsheet/readers/ods_reader.rb +71 -0
- data/lib/calcsheet/readers/ox_errors.rb +40 -0
- data/lib/calcsheet/readers/references.rb +62 -0
- data/lib/calcsheet/readers/row_batches.rb +45 -0
- data/lib/calcsheet/readers/sax_sheet.rb +108 -0
- data/lib/calcsheet/readers/stream_remainder.rb +66 -0
- data/lib/calcsheet/readers/tag_scan.rb +119 -0
- data/lib/calcsheet/readers/xlsx/escapes.rb +36 -0
- data/lib/calcsheet/readers/xlsx/local_name.rb +54 -0
- data/lib/calcsheet/readers/xlsx/number_format.rb +89 -0
- data/lib/calcsheet/readers/xlsx/row_skipper.rb +162 -0
- data/lib/calcsheet/readers/xlsx/shared_strings.rb +209 -0
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +435 -0
- data/lib/calcsheet/readers/xlsx/styles.rb +156 -0
- data/lib/calcsheet/readers/xlsx/workbook_meta.rb +228 -0
- data/lib/calcsheet/readers/xlsx_reader.rb +122 -0
- data/lib/calcsheet/reading.rb +281 -0
- data/lib/calcsheet/row.rb +110 -0
- data/lib/calcsheet/sample.rb +10 -0
- data/lib/calcsheet/schema.rb +140 -0
- data/lib/calcsheet/sheet_info.rb +21 -0
- data/lib/calcsheet/sheet_names.rb +114 -0
- data/lib/calcsheet/sheet_selection.rb +58 -0
- data/lib/calcsheet/source.rb +263 -0
- data/lib/calcsheet/stream_body.rb +81 -0
- data/lib/calcsheet/time_zone.rb +96 -0
- data/lib/calcsheet/untrusted.rb +32 -0
- data/lib/calcsheet/version.rb +6 -0
- data/lib/calcsheet/write_book.rb +202 -0
- data/lib/calcsheet/write_sheet.rb +282 -0
- data/lib/calcsheet/writers/csv_writer.rb +138 -0
- data/lib/calcsheet/writers/ods_writer.rb +437 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +350 -0
- data/lib/calcsheet/writers/xml_text.rb +35 -0
- data/lib/calcsheet/writers/zip_buffered.rb +55 -0
- data/lib/calcsheet/zip_container.rb +190 -0
- data/lib/calcsheet/zip_directory.rb +202 -0
- data/lib/calcsheet/zip_end_record.rb +117 -0
- data/lib/calcsheet.rb +295 -0
- metadata +200 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require_relative "cast"
|
|
6
|
+
require_relative "column"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "time_zone"
|
|
9
|
+
|
|
10
|
+
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
|
+
class WriteSheet
|
|
16
|
+
# What each column type accepts, for the message of a refused value.
|
|
17
|
+
EXPECTED = {
|
|
18
|
+
string: "a String", integer: "an Integer", decimal: "a finite BigDecimal or an Integer", date: "a Date",
|
|
19
|
+
datetime: "a Time", boolean: "true or false"
|
|
20
|
+
}.freeze
|
|
21
|
+
# Integers of fewer bits, and decimals with fewer digits and a smaller exponent, are written in less than
|
|
22
|
+
# Cast::MAX_NUMBER_BYTES characters (2**3000 has 904 digits), so only longer ones are measured.
|
|
23
|
+
SHORT_INTEGER_BITS = 3_000
|
|
24
|
+
# A decimal with fewer significant digits and a smaller exponent writes fewer than 1,024 characters.
|
|
25
|
+
SHORT_DECIMAL_DIGITS = 500
|
|
26
|
+
# Integers of more bits are measured from their bit length, not their text.
|
|
27
|
+
EXACT_INTEGER_BITS = 1_000_000
|
|
28
|
+
# The bound of #cell's quick text check without max_cell_bytes: no String is that long.
|
|
29
|
+
NO_TEXT_LIMIT = 1 << 62
|
|
30
|
+
# The unknown keys of a row a message lists.
|
|
31
|
+
SHOWN_KEYS = 10
|
|
32
|
+
|
|
33
|
+
# @return [String] the name of the sheet rows are written to, a continuation after an overflow
|
|
34
|
+
attr_reader :name
|
|
35
|
+
|
|
36
|
+
# @api private
|
|
37
|
+
# @param writer [Object] the writer backend
|
|
38
|
+
# @param names [SheetNames] the names of the workbook
|
|
39
|
+
# @param columns [Array<Hash, Column>]
|
|
40
|
+
# @param limit [Integer, nil] rows per sheet, header included; nil for no limit
|
|
41
|
+
# @param zone [#local, nil] the time_zone that converts written times; nil keeps their own wall clock
|
|
42
|
+
# @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
|
|
44
|
+
# @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?
|
|
47
|
+
|
|
48
|
+
@columns = columns.map { Column.from(_1) }.freeze
|
|
49
|
+
@types = @columns.map(&:type).freeze
|
|
50
|
+
raise ArgumentError, "header: must be true or false, got #{header.inspect}" unless header == true || header == false
|
|
51
|
+
|
|
52
|
+
@header = header
|
|
53
|
+
if @header && limit && limit < 2
|
|
54
|
+
raise ArgumentError, "max_rows_per_sheet: #{limit} leaves no room for data rows after the header"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@writer = writer
|
|
58
|
+
@names = names
|
|
59
|
+
@limit = limit
|
|
60
|
+
@zone = zone
|
|
61
|
+
@max_cell_bytes = max_cell_bytes
|
|
62
|
+
# The texts of fewer bytes fit whatever prefix the backend adds (one byte at most); one comparison per text.
|
|
63
|
+
@short_text = max_cell_bytes || NO_TEXT_LIMIT
|
|
64
|
+
@base = names.claim(name)
|
|
65
|
+
@part = 1
|
|
66
|
+
@closed = false
|
|
67
|
+
begin
|
|
68
|
+
check_headers(@base)
|
|
69
|
+
start(@base)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
# The sheet was refused (a header too long, too many columns...): nothing was written, so the name is free
|
|
72
|
+
# again.
|
|
73
|
+
names.release(@base)
|
|
74
|
+
raise
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param row [Array, Hash] values in column order, or keyed by each column's +key:+ (else its header); missing
|
|
79
|
+
# values are empty cells
|
|
80
|
+
# @return [self]
|
|
81
|
+
# @raise [WriteError] a value does not fit its column type or the format (a number longer than
|
|
82
|
+
# Cast::MAX_NUMBER_BYTES, a text longer than +max_cell_bytes+ or an XLSX cell, a date the format cannot hold), or
|
|
83
|
+
# the Array has more values than columns; nothing of the row is written and the sheet stays usable
|
|
84
|
+
# @raise [ArgumentError] the row is neither an Array nor a Hash, has unknown or ambiguous keys, or the sheet's
|
|
85
|
+
# block has ended
|
|
86
|
+
def <<(row)
|
|
87
|
+
raise ArgumentError, "sheet #{@name.inspect} is closed" if @closed
|
|
88
|
+
|
|
89
|
+
values = check(values_of(row))
|
|
90
|
+
if full?
|
|
91
|
+
problem = @writer.respond_to?(:row_problem) && @writer.row_problem(values)
|
|
92
|
+
raise failure(problem[1], problem[0]) if problem
|
|
93
|
+
|
|
94
|
+
continue
|
|
95
|
+
end
|
|
96
|
+
@writer.row(values)
|
|
97
|
+
@rows += 1
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Ends the sheet in the backend. Private, like #abandon: only WriteBook#sheet calls them (through +send+), once,
|
|
104
|
+
# after the block, so the block cannot end the sheet twice and corrupt the file.
|
|
105
|
+
def close
|
|
106
|
+
@closed = true
|
|
107
|
+
@writer.end_sheet
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Marks the sheet closed without ending it in the backend, when its block did not complete.
|
|
111
|
+
def abandon
|
|
112
|
+
@closed = true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def start(name)
|
|
116
|
+
@writer.start_sheet(name, @columns, header: @header)
|
|
117
|
+
@name = name
|
|
118
|
+
@rows = @header ? 1 : 0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def full?
|
|
122
|
+
!@limit.nil? && @rows >= @limit
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def continue
|
|
126
|
+
@writer.end_sheet
|
|
127
|
+
name, @part = @names.claim_continuation(@base, @part + 1)
|
|
128
|
+
start(name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def values_of(row)
|
|
132
|
+
case row
|
|
133
|
+
when Array
|
|
134
|
+
raise failure("#{row.size} values for #{@columns.size} columns") if row.size > @columns.size
|
|
135
|
+
|
|
136
|
+
row
|
|
137
|
+
when Hash then from_hash(row)
|
|
138
|
+
else raise ArgumentError, "row must be an Array or a Hash, got #{Untrusted.show(row)}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def from_hash(row)
|
|
143
|
+
values = Array.new(@columns.size)
|
|
144
|
+
unknown = nil
|
|
145
|
+
row.each do |key, value|
|
|
146
|
+
index = lookup[key]
|
|
147
|
+
index.nil? ? (unknown ||= []) << key : values[index] = value
|
|
148
|
+
end
|
|
149
|
+
raise ArgumentError, "unknown column key(s) #{shown_keys(unknown)}" if unknown
|
|
150
|
+
|
|
151
|
+
values
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The first SHOWN_KEYS keys, each cut (Untrusted.show), and how many more there are.
|
|
155
|
+
def shown_keys(keys)
|
|
156
|
+
shown = keys.first(SHOWN_KEYS).map { Untrusted.show(_1) }.join(", ")
|
|
157
|
+
keys.size > SHOWN_KEYS ? "#{shown} and #{keys.size - SHOWN_KEYS} more" : shown
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def lookup
|
|
161
|
+
@lookup ||= @columns.each_with_index.with_object({}) do |(column, index), keys|
|
|
162
|
+
key = column.lookup_key
|
|
163
|
+
raise ArgumentError, "ambiguous column key #{Untrusted.show(key)}: give the columns distinct key: values" if keys.key?(key)
|
|
164
|
+
|
|
165
|
+
keys[key] = index
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def check(values)
|
|
170
|
+
Array.new(@columns.size) do |index|
|
|
171
|
+
value = values[index]
|
|
172
|
+
value.nil? ? nil : cell(value, index)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def cell(value, index)
|
|
177
|
+
type = @types[index]
|
|
178
|
+
case type
|
|
179
|
+
when :string
|
|
180
|
+
if value.is_a?(String)
|
|
181
|
+
text = Column.utf8(value)
|
|
182
|
+
raise failure("text cannot be converted to UTF-8, got #{Untrusted.show(value)}", index) if text.nil?
|
|
183
|
+
|
|
184
|
+
return text.bytesize < @short_text ? text : long_text(text, index)
|
|
185
|
+
end
|
|
186
|
+
when :integer
|
|
187
|
+
# The bit length of a common Integer is checked here, without a method call per cell.
|
|
188
|
+
return value.bit_length < SHORT_INTEGER_BITS ? value : number(value, index) if value.is_a?(Integer)
|
|
189
|
+
when :decimal
|
|
190
|
+
return value.bit_length < SHORT_INTEGER_BITS ? value : number(value, index) if value.is_a?(Integer)
|
|
191
|
+
return number(value, index) if value.is_a?(BigDecimal) && value.finite?
|
|
192
|
+
when :date then return value if value.is_a?(Date) && !value.is_a?(DateTime)
|
|
193
|
+
when :datetime then return wall_clock(value) if value.is_a?(Time)
|
|
194
|
+
when :boolean then return value if true.equal?(value) || false.equal?(value)
|
|
195
|
+
end
|
|
196
|
+
raise failure("expected #{EXPECTED.fetch(type)} (:#{type} column), got #{Untrusted.show(value)}", index)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A number whose written text (the full digits of an Integer, the plain text of a BigDecimal) is longer than
|
|
200
|
+
# Cast::MAX_NUMBER_BYTES would make the file unreadable: the readers refuse longer numbers. The length is
|
|
201
|
+
# computed without the text, which for BigDecimal("1e500000000") would take gigabytes. An Integer comes here only
|
|
202
|
+
# with SHORT_INTEGER_BITS bits or more: #cell returns the common short ones itself.
|
|
203
|
+
def number(value, index)
|
|
204
|
+
if value.is_a?(Integer)
|
|
205
|
+
length = integer_length(value)
|
|
206
|
+
return value if length.is_a?(Integer) && length <= Cast::MAX_NUMBER_BYTES
|
|
207
|
+
else
|
|
208
|
+
return value if value.exponent.abs < SHORT_DECIMAL_DIGITS && value.n_significant_digits < SHORT_DECIMAL_DIGITS
|
|
209
|
+
|
|
210
|
+
length = self.class.plain_length(value)
|
|
211
|
+
return value if length <= Cast::MAX_NUMBER_BYTES
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
raise failure("the number is #{length} characters long, more than the #{Cast::MAX_NUMBER_BYTES} that a reader reads", index)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A text the readers would refuse: longer than max_cell_bytes as the backend writes it (CSV puts a "'" before a
|
|
218
|
+
# text that starts like a formula, see #text_bytesize in the WriteBook contract).
|
|
219
|
+
def long_text(text, index)
|
|
220
|
+
length = @writer.respond_to?(:text_bytesize) ? @writer.text_bytesize(text, index) : text.bytesize
|
|
221
|
+
return text if length <= @max_cell_bytes
|
|
222
|
+
|
|
223
|
+
raise failure("the text is #{length} bytes long, more than the #{@max_cell_bytes} of max_cell_bytes that a reader reads", index)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A written header the readers would refuse: longer than max_cell_bytes as the backend writes it (CSV puts a "'"
|
|
227
|
+
# before a header that starts like a formula, see #header_bytesize in the WriteBook contract). Checked before the
|
|
228
|
+
# backend starts the sheet, so that nothing of it is written.
|
|
229
|
+
def check_headers(name)
|
|
230
|
+
return unless @header && @max_cell_bytes
|
|
231
|
+
|
|
232
|
+
@columns.each_with_index do |column, index|
|
|
233
|
+
length = @writer.respond_to?(:header_bytesize) ? @writer.header_bytesize(column) : column.header.bytesize
|
|
234
|
+
next if length <= @max_cell_bytes
|
|
235
|
+
|
|
236
|
+
raise WriteError.at("the header is #{length} bytes long, more than the #{@max_cell_bytes} of max_cell_bytes that a " \
|
|
237
|
+
"reader reads", sheet: name, row: 1, column_index: index, header: column.header)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The digits (and sign) of an Integer; beyond EXACT_INTEGER_BITS, where its text would cost too much to build,
|
|
242
|
+
# "about N" from its bit length (exact for a power of two).
|
|
243
|
+
def integer_length(value)
|
|
244
|
+
return value.to_s.bytesize if value.bit_length <= EXACT_INTEGER_BITS
|
|
245
|
+
|
|
246
|
+
"about #{((value.bit_length - 1) * Math.log10(2)).floor + 1 + (value.negative? ? 1 : 0)}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @api private
|
|
250
|
+
# The bytes of DecimalText.plain of a finite BigDecimal, from its sign, exponent and significant
|
|
251
|
+
# digits: "123.45", "0.00123" or "12300". The value is 0.ddd x 10**exponent with +digits+ significant digits: an
|
|
252
|
+
# exponent of at least +digits+ is an integer of +exponent+ digits; a positive one below it puts the point inside
|
|
253
|
+
# the digits (one byte more); zero or a negative one writes "0.", -exponent zeros, then the digits.
|
|
254
|
+
# @return [Integer]
|
|
255
|
+
def self.plain_length(value)
|
|
256
|
+
sign = value.sign.negative? ? 1 : 0
|
|
257
|
+
return sign + 1 if value.zero?
|
|
258
|
+
|
|
259
|
+
digits = value.n_significant_digits
|
|
260
|
+
exponent = value.exponent
|
|
261
|
+
length =
|
|
262
|
+
if exponent >= digits then exponent
|
|
263
|
+
elsif exponent.positive? then digits + 1
|
|
264
|
+
else 2 - exponent + digits
|
|
265
|
+
end
|
|
266
|
+
sign + length
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The wall clock to write, in whole seconds, as a UTC Time (the writer backend contract).
|
|
270
|
+
def wall_clock(time)
|
|
271
|
+
time = time.round
|
|
272
|
+
fields = @zone ? TimeZone.wall_clock(@zone, time) : [ time.year, time.month, time.day, time.hour, time.min, time.sec ]
|
|
273
|
+
Time.utc(*fields)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A WriteError located where the row would be written: the next row, or a continuation's first data row.
|
|
277
|
+
def failure(reason, index = nil)
|
|
278
|
+
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)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "csv"
|
|
5
|
+
require "date"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require_relative "../decimal_text"
|
|
8
|
+
require_relative "../errors"
|
|
9
|
+
require_relative "../formula_protection"
|
|
10
|
+
|
|
11
|
+
module Calcsheet
|
|
12
|
+
# The writer backends registered in Calcsheet.writers (CsvWriter, XlsxWriter, OdsWriter), which implement the
|
|
13
|
+
# contract of WriteBook, and what they share: XmlText and ZipBuffered.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
module Writers
|
|
17
|
+
# CSV writer backend, see the contract in WriteBook. A CSV file holds one sheet and has no row limit.
|
|
18
|
+
# 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
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
class CsvWriter
|
|
23
|
+
# The UTF-8 byte order mark of +bom: true+, written as an escape: the character is invisible.
|
|
24
|
+
BOM = "\uFEFF"
|
|
25
|
+
# excel_it: true presets these values for the options left nil, and "," for a decimal_separator not given per
|
|
26
|
+
# call.
|
|
27
|
+
EXCEL_IT = { col_sep: ";", bom: true, date_format: "%d/%m/%Y", datetime_format: "%d/%m/%Y %H:%M:%S" }.freeze
|
|
28
|
+
# The values of the same options without the preset.
|
|
29
|
+
PLAIN = { col_sep: ",", bom: false, date_format: "%Y-%m-%d", datetime_format: "%Y-%m-%d %H:%M:%S" }.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# @return [nil] CSV has no row limit
|
|
33
|
+
def max_rows_per_sheet
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolves the excel_it preset into the options, explicit values winning (see the contract in WriteBook).
|
|
38
|
+
# The values are checked already (OptionValidation), so this raises nothing.
|
|
39
|
+
# @param explicit [Array<Symbol>] the option keys given per call
|
|
40
|
+
# @return [Options]
|
|
41
|
+
def prepare_options(options, explicit:)
|
|
42
|
+
options.with(**dialect(options, explicit: explicit))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param explicit [Array<Symbol>] the option keys given per call; decimal_separator wins over the preset
|
|
46
|
+
# only when listed
|
|
47
|
+
# @return [Hash] col_sep, row_sep, bom, decimal_separator, date_format, datetime_format
|
|
48
|
+
def dialect(options, explicit:)
|
|
49
|
+
excel_it = options.excel_it
|
|
50
|
+
preset = excel_it ? EXCEL_IT : PLAIN
|
|
51
|
+
dialect = preset.to_h { |key, default| [ key, options.public_send(key).nil? ? default : options.public_send(key) ] }
|
|
52
|
+
dialect[:row_sep] = options.row_sep
|
|
53
|
+
dialect[:decimal_separator] = excel_it && !explicit.include?(:decimal_separator) ? "," : options.decimal_separator
|
|
54
|
+
dialect.freeze
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param io [#write] the output, never closed here
|
|
59
|
+
# @param options [Options] as returned by prepare_options (their decimal_separator is taken as given)
|
|
60
|
+
def initialize(io, options)
|
|
61
|
+
@io = io
|
|
62
|
+
@dialect = self.class.dialect(options, explicit: [ :decimal_separator ])
|
|
63
|
+
@line = StringIO.new(+"")
|
|
64
|
+
@csv = CSV.new(@line, col_sep: @dialect[:col_sep], row_sep: @dialect[:row_sep])
|
|
65
|
+
@started = false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Writes the BOM, if any, and the header row.
|
|
69
|
+
# @raise [WriteError] for a second sheet
|
|
70
|
+
def start_sheet(name, columns, header:)
|
|
71
|
+
raise WriteError.new("a CSV file holds a single sheet: cannot start sheet '#{name}'", sheet: name) if @started
|
|
72
|
+
|
|
73
|
+
@started = true
|
|
74
|
+
@protected = columns.map { |column| column.type == :string && column.formula_protection }
|
|
75
|
+
@io.write(BOM) if @dialect[:bom]
|
|
76
|
+
emit(columns.map { header_text(_1) }) if header
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Writes one record: each value as its text (see #text), quoted by the stdlib CSV where needed.
|
|
80
|
+
def row(values)
|
|
81
|
+
emit(values.each_with_index.map { |value, index| text(value, index) })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @param text [String] a text of the column at +index+ (WriteBook contract)
|
|
85
|
+
# @return [Integer] the bytes written for it, the "'" of formula protection included
|
|
86
|
+
def text_bytesize(text, index)
|
|
87
|
+
@protected[index] ? FormulaProtection.protect(text).bytesize : text.bytesize
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param column [Column] a column of a sheet about to start (WriteBook contract)
|
|
91
|
+
# @return [Integer] the bytes written for its header, the "'" of formula protection included
|
|
92
|
+
def header_bytesize(column)
|
|
93
|
+
header_text(column).bytesize
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Nothing ends a CSV sheet.
|
|
97
|
+
def end_sheet
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Nothing completes a CSV file.
|
|
101
|
+
def finish
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def emit(fields)
|
|
107
|
+
@csv << fields
|
|
108
|
+
@io.write(@line.string)
|
|
109
|
+
# A new String for every line: the target may keep the one it was given.
|
|
110
|
+
@line.string = +""
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The header of a column as written: protected unless the column disables formula_protection, whatever its type.
|
|
114
|
+
def header_text(column)
|
|
115
|
+
column.formula_protection ? FormulaProtection.protect(column.header) : column.header
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The text of a value: protected text, digits, the plain decimal, the formatted date or time, "true"/"false".
|
|
119
|
+
def text(value, index)
|
|
120
|
+
case value
|
|
121
|
+
when nil then nil
|
|
122
|
+
when String then @protected[index] ? FormulaProtection.protect(value) : value
|
|
123
|
+
when Integer then value.to_s
|
|
124
|
+
when BigDecimal then decimal(value)
|
|
125
|
+
when Time then value.strftime(@dialect[:datetime_format])
|
|
126
|
+
when Date then value.strftime(@dialect[:date_format])
|
|
127
|
+
else value.to_s # true or false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The exact value without exponent, its integral ".0" dropped.
|
|
132
|
+
def decimal(value)
|
|
133
|
+
text = DecimalText.plain(value)
|
|
134
|
+
@dialect[:decimal_separator] == "." ? text : text.tr(".", @dialect[:decimal_separator])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|