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,578 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require "set"
|
|
6
|
+
require_relative "decimal_text"
|
|
7
|
+
require_relative "excel_serial"
|
|
8
|
+
require_relative "time_zone"
|
|
9
|
+
|
|
10
|
+
module Calcsheet
|
|
11
|
+
# The types a column can declare.
|
|
12
|
+
TYPES = %i[string integer decimal date datetime boolean].freeze
|
|
13
|
+
|
|
14
|
+
# Turns cell text into native or declared Ruby values. Every reader backend goes
|
|
15
|
+
# through it, so the methods avoid needless allocations: patterns are compiled once,
|
|
16
|
+
# decimals are built from the text (never through Float) and native strings are
|
|
17
|
+
# returned without copying. A value that cannot be converted raises Failure.
|
|
18
|
+
#
|
|
19
|
+
# The +type+ of each method is a member of TYPES, or nil for the native value; another raises ArgumentError.
|
|
20
|
+
class Cast
|
|
21
|
+
# Raised when a cell cannot be converted. Internal and lightweight: the reader turns it
|
|
22
|
+
# into a Calcsheet::CellError with the position of the cell.
|
|
23
|
+
class Failure < StandardError
|
|
24
|
+
# @return [Symbol] :invalid_integer, :not_integral, :invalid_decimal, :invalid_date,
|
|
25
|
+
# :invalid_datetime or :invalid_boolean; :cell_error for an error cell, which the ODS and XLSX readers raise
|
|
26
|
+
# or record themselves
|
|
27
|
+
attr_reader :reason
|
|
28
|
+
# @return [Object] the raw input
|
|
29
|
+
attr_reader :value
|
|
30
|
+
|
|
31
|
+
# @param reason [Symbol] see #reason
|
|
32
|
+
# @param value [Object] the raw input
|
|
33
|
+
def initialize(reason, value)
|
|
34
|
+
super(reason.to_s)
|
|
35
|
+
@reason = reason
|
|
36
|
+
@value = value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The reason of a value that a declared type cannot take.
|
|
41
|
+
FAILURES = {
|
|
42
|
+
integer: :invalid_integer, decimal: :invalid_decimal, date: :invalid_date,
|
|
43
|
+
datetime: :invalid_datetime, boolean: :invalid_boolean
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# The longest text of a number, a date, a time or a duration. The patterns below are only ever matched
|
|
47
|
+
# against text this long or shorter: their backtracking costs memory in proportion to the length of the text.
|
|
48
|
+
MAX_NUMBER_BYTES = 1_024
|
|
49
|
+
|
|
50
|
+
# A number as BigDecimal reads it: a sign, digits with an optional point, an exponent of at most four digits.
|
|
51
|
+
NUMBER = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d{1,4})?\z/
|
|
52
|
+
# An integer: a sign and digits.
|
|
53
|
+
INTEGER = /\A[+-]?\d+\z/
|
|
54
|
+
# Digits only.
|
|
55
|
+
DIGITS = /\A\d+\z/
|
|
56
|
+
# A date serial, not negative, without exponent: the common text of an XLSX date (see #serial_units).
|
|
57
|
+
SERIAL = /\A\d+(?:\.\d+)?\z/
|
|
58
|
+
# An ODS date-value or XLSX t="d" value: date, then optionally the time, its fraction and a "Z" or "+hh:mm" offset.
|
|
59
|
+
ISO_DATE = /\A(\d{4})-(\d\d)-(\d\d)(?:T(\d\d):(\d\d):(\d\d)(?:\.(\d+))?(?:(Z)|([+-])(\d\d):(\d\d))?)?\z/
|
|
60
|
+
# A character that is not whitespace: a text without one is blank.
|
|
61
|
+
NON_BLANK = /[^[:space:]]/
|
|
62
|
+
# An ODS time-value: an xsd:duration of days, hours, minutes and seconds (with a fraction).
|
|
63
|
+
DURATION = /\AP(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.(\d+))?S)?)?\z/
|
|
64
|
+
|
|
65
|
+
# The strptime directives that date_formats and datetime_formats may use, with the text each one reads (the
|
|
66
|
+
# widths Date._strptime takes). A %Y year has exactly four digits. Date._strptime reads the digits of a numeric
|
|
67
|
+
# directive greedily and never gives one back, so they are atomic groups: otherwise "%d%m%Y" would match
|
|
68
|
+
# "0102026" as 01|0|2026, where Date._strptime reads 01|02|026. Date._strptime also reads one digit after a
|
|
69
|
+
# leading space for %d and %e, %H and %k, %I and %l alike; the patterns accept that space only for %e, %k and %l,
|
|
70
|
+
# so a %d, %H or %I text with a space before one digit passes only when the year anchor spares it the pattern
|
|
71
|
+
# (see .date_format).
|
|
72
|
+
FORMAT_DIRECTIVES = {
|
|
73
|
+
"Y" => "\\d{4}", "y" => "(?>\\d{1,2})", "m" => "(?>\\d{1,2})", "d" => "(?>\\d{1,2})", "e" => "(?> \\d|\\d{1,2})",
|
|
74
|
+
"b" => "[[:alpha:]]+", "B" => "[[:alpha:]]+", "h" => "[[:alpha:]]+", "a" => "[[:alpha:]]+", "A" => "[[:alpha:]]+",
|
|
75
|
+
"H" => "(?>\\d{1,2})", "k" => "(?> \\d|\\d{1,2})", "I" => "(?>\\d{1,2})", "l" => "(?> \\d|\\d{1,2})", "M" => "(?>\\d{1,2})",
|
|
76
|
+
"S" => "(?>\\d{1,2})", "p" => "(?i:[ap]\\.?m\\.?)", "P" => "(?i:[ap]\\.?m\\.?)",
|
|
77
|
+
"F" => "\\d{4}-(?>\\d{1,2})-(?>\\d{1,2})", "T" => "(?>\\d{1,2}):(?>\\d{1,2}):(?>\\d{1,2})",
|
|
78
|
+
"R" => "(?>\\d{1,2}):(?>\\d{1,2})", "%" => "%"
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
# Excel keeps 15 significant digits; a double printed in full (the shortest text that reads back as the same
|
|
82
|
+
# double) has up to 17, so 16 or 17 are the binary artifact of a value Excel saw with 15.
|
|
83
|
+
SIGNIFICANT_DIGITS = 15
|
|
84
|
+
# The significant digits of the full print of a double that Excel saw with 15.
|
|
85
|
+
DOUBLE_DIGITS = 16..17
|
|
86
|
+
# Beyond this a whole number stays a BigDecimal instead of becoming a huge Integer.
|
|
87
|
+
MAX_INTEGER_EXPONENT = 30
|
|
88
|
+
# The units of a day for date serials (seconds).
|
|
89
|
+
SECONDS_PER_DAY = ExcelSerial::SECONDS_PER_DAY
|
|
90
|
+
# The units of a day for time serials and durations (milliseconds).
|
|
91
|
+
MILLIS_PER_DAY = SECONDS_PER_DAY * 1000
|
|
92
|
+
|
|
93
|
+
# The pieces of a date format: a directive (or a "%" ending it), a run of whitespace, or other text.
|
|
94
|
+
FORMAT_PIECE = /%(.)|%\z|\s+|[^%\s]+/m
|
|
95
|
+
# A format with a year that Date._strptime would read at any length: it gets a pattern (see .date_format).
|
|
96
|
+
YEAR_DIRECTIVES = /%[YF]/
|
|
97
|
+
# Directives that read digits only (with a space before them for %e and %k), and the literal text that can
|
|
98
|
+
# anchor a year after them: no digit, letter or whitespace.
|
|
99
|
+
NUMERIC_DIRECTIVES = %w[%m %d %e %H %k %M %S %y %%].freeze
|
|
100
|
+
# Literal text that can anchor a year: no digit, letter, whitespace or "%".
|
|
101
|
+
ANCHOR_TEXT = /\A[^[:alnum:][:space:]%]+\z/
|
|
102
|
+
|
|
103
|
+
# A date or datetime format compiled once per Cast, as +[format, pattern, anchor]+. Date._strptime reads a %Y year
|
|
104
|
+
# of any length and sign, so a format with %Y (or %F) gets a pattern that the text must match: each directive as
|
|
105
|
+
# FORMAT_DIRECTIVES gives it, whitespace as any whitespace (as Date._strptime reads it), every other character as
|
|
106
|
+
# itself. Matching it for every date would cost about 3% of a CSV read, so the anchor tells where the year text
|
|
107
|
+
# starts in a text Date._strptime read: 0 for the start, the byte before a %Y that ends the text, or a character
|
|
108
|
+
# whose last occurrence precedes it (only numeric directives and other characters before the year, none of that
|
|
109
|
+
# character after it). A year from 1000 to 9999 whose text starts there with a digit 1-9 has four digits; any
|
|
110
|
+
# other year, or a format without an anchor, is checked on the pattern (see #parse_fields).
|
|
111
|
+
#
|
|
112
|
+
# @api private
|
|
113
|
+
# @param format [String] a format of the supported directives (OptionValidation checks them)
|
|
114
|
+
# @return [Array(String, Regexp | nil, Integer | String | nil)]
|
|
115
|
+
# @raise [ArgumentError] for another directive
|
|
116
|
+
def self.date_format(format)
|
|
117
|
+
return [ format, nil, nil ].freeze unless YEAR_DIRECTIVES.match?(format)
|
|
118
|
+
|
|
119
|
+
pieces = []
|
|
120
|
+
format.scan(FORMAT_PIECE) { pieces << Regexp.last_match(0) }
|
|
121
|
+
source = pieces.map do |piece|
|
|
122
|
+
if piece.start_with?("%")
|
|
123
|
+
FORMAT_DIRECTIVES.fetch(piece[1]) { raise ArgumentError, "unsupported directive #{piece} in #{format.inspect}" }
|
|
124
|
+
elsif piece.match?(/\A\s/) then "\\s*"
|
|
125
|
+
else Regexp.escape(piece)
|
|
126
|
+
end
|
|
127
|
+
end.join
|
|
128
|
+
[ format, Regexp.new("\\A#{source}\\z"), year_anchor(pieces) ].freeze
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Integer, String, nil] 0 when the first piece is %Y or %F; the last byte of the character before a %Y
|
|
132
|
+
# that ends the format; the character after whose last occurrence the first %Y or %F starts; nil when another
|
|
133
|
+
# directive or whitespace precedes it, or the character follows it. Offsets are in bytes throughout.
|
|
134
|
+
def self.year_anchor(pieces)
|
|
135
|
+
year = pieces.index { %w[%Y %F].include?(_1) } or return
|
|
136
|
+
return 0 if year.zero?
|
|
137
|
+
|
|
138
|
+
before = pieces.first(year)
|
|
139
|
+
return unless before.all? { |piece| NUMERIC_DIRECTIVES.include?(piece) || piece.match?(ANCHOR_TEXT) }
|
|
140
|
+
return if before.last.start_with?("%")
|
|
141
|
+
|
|
142
|
+
char = before.last[-1]
|
|
143
|
+
after = pieces.drop(year + 1)
|
|
144
|
+
return if after.any? { |piece| piece.include?(char) || %w[%p %P].include?(piece) }
|
|
145
|
+
|
|
146
|
+
# A year that ends the text (a %Y last) starts 4 bytes before the end, after the character.
|
|
147
|
+
after.empty? && pieces[year] == "%Y" ? char.bytes.last : char
|
|
148
|
+
end
|
|
149
|
+
private_class_method :year_anchor
|
|
150
|
+
|
|
151
|
+
# @return [Options] the options of the read, for backends that apply format-specific ones themselves
|
|
152
|
+
attr_reader :options
|
|
153
|
+
|
|
154
|
+
# @param options [Options]
|
|
155
|
+
# @param date1904 [Boolean] the workbook counts days from 1904-01-01
|
|
156
|
+
def initialize(options, date1904: false)
|
|
157
|
+
@options = options
|
|
158
|
+
@date1904 = date1904
|
|
159
|
+
@zone = TimeZone.wrap(options.time_zone)
|
|
160
|
+
# Instants (ISO datetimes with "Z" or an offset) are converted into the zone when it can do so; a zone that
|
|
161
|
+
# only builds times from fields (responds to local alone) leaves them in UTC.
|
|
162
|
+
@instant_zone = TimeZone.converts?(@zone) ? @zone : TimeZone::UTC
|
|
163
|
+
@float = options.decimals == :float
|
|
164
|
+
@decimal_separator = options.decimal_separator
|
|
165
|
+
@thousands_separator = options.thousands_separator.to_s.empty? ? nil : options.thousands_separator
|
|
166
|
+
@plain_numbers = @decimal_separator == "." && @thousands_separator.nil?
|
|
167
|
+
# Digits grouped by three from the decimal separator, which a text with the thousands separator must match.
|
|
168
|
+
@grouped = @thousands_separator &&
|
|
169
|
+
/\A[+-]?\d{1,3}(?:#{Regexp.escape(@thousands_separator)}\d{3})*(?:#{Regexp.escape(@decimal_separator)}\d+)?\z/
|
|
170
|
+
@date_formats = options.date_formats.map { self.class.date_format(_1) }
|
|
171
|
+
@datetime_formats = options.datetime_formats.map { self.class.date_format(_1) }
|
|
172
|
+
@true_words = options.true_words.to_set { _1.downcase }
|
|
173
|
+
@false_words = options.false_words.to_set { _1.downcase }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A text cell (CSV, XLSX and ODS strings).
|
|
177
|
+
# @param str [String]
|
|
178
|
+
# @param type [Symbol, nil] a member of TYPES, or nil for the native value (the string itself)
|
|
179
|
+
# @return [Object, nil] the value; nil for a whitespace-only text read as any type but :string
|
|
180
|
+
# @raise [Failure] the text is not a value of the type
|
|
181
|
+
def text(str, type)
|
|
182
|
+
case type
|
|
183
|
+
when nil, :string then str
|
|
184
|
+
when :integer then text_integer(str)
|
|
185
|
+
when :decimal then text_decimal(str)
|
|
186
|
+
when :date then text_date(str)
|
|
187
|
+
when :datetime then text_datetime(str)
|
|
188
|
+
when :boolean then boolean_text(str)
|
|
189
|
+
else reject(type, str)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A numeric cell.
|
|
194
|
+
# @param text [String] the cell text
|
|
195
|
+
# @param kind [Symbol] :general, or :date, :datetime or :time when the number format makes it a serial
|
|
196
|
+
# @param type [Symbol, nil] a member of TYPES, or nil for the native value
|
|
197
|
+
# @param pad [Integer, nil] digits of a zero-padding number format, restored for :string
|
|
198
|
+
# @return [Object] the value: native, an Integer, BigDecimal (or Float with +decimals: :float+), Date or Time;
|
|
199
|
+
# seconds for :time
|
|
200
|
+
# @raise [Failure] the text is no number, is longer than MAX_NUMBER_BYTES, or is not a value of the type (a date
|
|
201
|
+
# type for a :general number, for instance)
|
|
202
|
+
# @raise [ArgumentError] an unknown +kind+
|
|
203
|
+
def number(text, kind, type, pad: nil)
|
|
204
|
+
if text.bytesize > MAX_NUMBER_BYTES
|
|
205
|
+
raise Failure.new(FAILURES[type] || (%i[date datetime].include?(kind) ? :"invalid_#{kind}" : :invalid_decimal), text)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
case kind
|
|
209
|
+
when :general then general_number(text, type, pad)
|
|
210
|
+
when :date, :datetime then date_serial(text, kind, type)
|
|
211
|
+
when :time then time_serial(text, type)
|
|
212
|
+
else raise ArgumentError, "unknown number kind: #{kind.inspect}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# An ODS +date-value+ or XLSX +t="d"+ value: "2026-01-01" or "2026-01-01T10:00:00[.fff]", wall-clock fields
|
|
217
|
+
# read in the time zone. A datetime with a "Z" or "+hh:mm" suffix is an instant, given in UTC, or in the time zone
|
|
218
|
+
# when one is configured that can convert instants (see TimeZone.converts?); in UTC otherwise.
|
|
219
|
+
# @param kind [Symbol, nil] :date or :datetime when the cell's number format decides it (a date drops the time, a
|
|
220
|
+
# datetime may be at midnight); otherwise the text decides
|
|
221
|
+
# @return [Date, Time, String] a Date, a Time, or for :string the ISO text
|
|
222
|
+
# @raise [Failure] an invalid date, time or offset, a year 0, or a declared :integer, :decimal or :boolean
|
|
223
|
+
def iso_date(text, type, kind = nil)
|
|
224
|
+
return invalid_iso_date(text, type) if text.bytesize > MAX_NUMBER_BYTES
|
|
225
|
+
|
|
226
|
+
match = ISO_DATE.match(text) or return invalid_iso_date(text, type)
|
|
227
|
+
year = match[1].to_i
|
|
228
|
+
month = match[2].to_i
|
|
229
|
+
day = match[3].to_i
|
|
230
|
+
# Spreadsheet dates start in the year 1; Ruby's proleptic calendar would take 0000 too.
|
|
231
|
+
return invalid_iso_date(text, type) unless year.positive? && Date.valid_civil?(year, month, day)
|
|
232
|
+
|
|
233
|
+
date = Date.new(year, month, day)
|
|
234
|
+
return temporal(date, 0, kind == :datetime ? :datetime : :date, type, text) unless match[4]
|
|
235
|
+
|
|
236
|
+
hour = match[4].to_i
|
|
237
|
+
min = match[5].to_i
|
|
238
|
+
sec = match[6].to_i
|
|
239
|
+
return invalid_iso_date(text, type) unless hour < 24 && min < 60 && sec < 60
|
|
240
|
+
|
|
241
|
+
seconds = hour * 3600 + min * 60 + sec
|
|
242
|
+
seconds += 1 if match[7] && match[7].getbyte(0) >= 53 # first fractional digit >= "5"
|
|
243
|
+
if seconds >= SECONDS_PER_DAY
|
|
244
|
+
date += 1
|
|
245
|
+
seconds -= SECONDS_PER_DAY
|
|
246
|
+
end
|
|
247
|
+
zone = @zone
|
|
248
|
+
date, seconds, zone = in_zone(date, seconds, match, text, type) if match[8] || match[9]
|
|
249
|
+
return temporal(date, 0, :date, type, text) if kind == :date
|
|
250
|
+
|
|
251
|
+
temporal(date, seconds, :datetime, type, text, zone)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# An ODS +time-value+: "PT12H30M05S".
|
|
255
|
+
# @return [Integer, BigDecimal, Float, String] whole seconds, fractional ones (rounded to the millisecond), or
|
|
256
|
+
# for :string the clock text "12:30:05"
|
|
257
|
+
# @raise [Failure] an invalid duration, or a type that a duration cannot be
|
|
258
|
+
def duration(text, type)
|
|
259
|
+
match = DURATION.match(text) unless text.bytesize > MAX_NUMBER_BYTES
|
|
260
|
+
unless match && (match[1] || match[2] || match[3] || match[4])
|
|
261
|
+
raise Failure.new(FAILURES[type] || :invalid_decimal, text)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
seconds = (((match[1].to_i * 24 + match[2].to_i) * 60 + match[3].to_i) * 60) + match[4].to_i
|
|
265
|
+
millis = seconds * 1000
|
|
266
|
+
if (fraction = match[5])
|
|
267
|
+
scale = 10**fraction.size
|
|
268
|
+
millis += (fraction.to_i * 2000 + scale) / (scale * 2)
|
|
269
|
+
end
|
|
270
|
+
seconds_value(millis, type, text)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A boolean cell.
|
|
274
|
+
# @param value [Boolean]
|
|
275
|
+
# @return [Boolean, String, Integer, BigDecimal] the value; "true"/"false", 1/0 for the other types
|
|
276
|
+
# @raise [Failure] for :date and :datetime
|
|
277
|
+
def boolean(value, type)
|
|
278
|
+
case type
|
|
279
|
+
when nil, :boolean then value
|
|
280
|
+
when :string then value.to_s
|
|
281
|
+
when :integer then value ? 1 : 0
|
|
282
|
+
when :decimal then BigDecimal(value ? 1 : 0)
|
|
283
|
+
else reject(type, value)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def reject(type, value)
|
|
290
|
+
reason = FAILURES[type] or raise ArgumentError, "unknown type: #{type.inspect}"
|
|
291
|
+
raise Failure.new(reason, value)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# --- text cells ---------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
# A text with the thousands separator must have its integer digits grouped by three: "12.5" or
|
|
297
|
+
# "1.2.3,5" is no number when "." groups thousands, rather than 125 or 123.5.
|
|
298
|
+
# @return [String, nil] the text with "." as decimal point and no grouping; nil when it cannot be a number or is
|
|
299
|
+
# longer than MAX_NUMBER_BYTES
|
|
300
|
+
def normalize(str)
|
|
301
|
+
string = str.strip
|
|
302
|
+
return if string.bytesize > MAX_NUMBER_BYTES
|
|
303
|
+
return string if @plain_numbers
|
|
304
|
+
|
|
305
|
+
if @thousands_separator && string.include?(@thousands_separator)
|
|
306
|
+
return unless @grouped.match?(string)
|
|
307
|
+
|
|
308
|
+
string = string.gsub(@thousands_separator, "")
|
|
309
|
+
end
|
|
310
|
+
unless @decimal_separator == "."
|
|
311
|
+
# A dot that is neither the decimal nor the thousands separator is not a number.
|
|
312
|
+
return nil if string.include?(".")
|
|
313
|
+
|
|
314
|
+
string = string.gsub(@decimal_separator, ".")
|
|
315
|
+
end
|
|
316
|
+
string
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def text_integer(str)
|
|
320
|
+
string = normalize(str)
|
|
321
|
+
return blank_or_invalid(:invalid_integer, str) unless string
|
|
322
|
+
return string.to_i if INTEGER.match?(string)
|
|
323
|
+
return blank_or_invalid(:invalid_integer, str) unless NUMBER.match?(string)
|
|
324
|
+
|
|
325
|
+
integral(BigDecimal(string), str)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def text_decimal(str)
|
|
329
|
+
string = normalize(str)
|
|
330
|
+
return blank_or_invalid(:invalid_decimal, str) unless string && NUMBER.match?(string)
|
|
331
|
+
|
|
332
|
+
BigDecimal(string)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def text_date(str)
|
|
336
|
+
string = str.strip
|
|
337
|
+
fields = parse_fields(string, @date_formats) || parse_fields(string, @datetime_formats)
|
|
338
|
+
return blank_or_invalid(:invalid_date, str) unless fields
|
|
339
|
+
|
|
340
|
+
Date.new(fields[0], fields[1], fields[2])
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def text_datetime(str)
|
|
344
|
+
string = str.strip
|
|
345
|
+
fields = parse_fields(string, @datetime_formats) || parse_fields(string, @date_formats)
|
|
346
|
+
return blank_or_invalid(:invalid_datetime, str) unless fields
|
|
347
|
+
|
|
348
|
+
@zone.local(*fields)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A text of whitespace only is a blank cell for every declared type but :string: nil, which a required column
|
|
352
|
+
# reports as :required. Checked only once a cast failed, so that a valid text costs nothing more.
|
|
353
|
+
def blank_or_invalid(reason, str)
|
|
354
|
+
return unless NON_BLANK.match?(str)
|
|
355
|
+
|
|
356
|
+
raise Failure.new(reason, str)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# The first format that matches the whole text with a real calendar date and time of day (see .date_format). A
|
|
360
|
+
# %Y year from 1000 to 9999 had four digits, unless the text has a "+" or leading zeros that leave its value
|
|
361
|
+
# right; any other year is checked on the pattern.
|
|
362
|
+
# @return [Array<Integer>, nil] year, month, day, hour, minute, second
|
|
363
|
+
def parse_fields(string, formats)
|
|
364
|
+
return if string.bytesize > MAX_NUMBER_BYTES
|
|
365
|
+
|
|
366
|
+
formats.each do |format, pattern, anchor|
|
|
367
|
+
fields = Date._strptime(string, format)
|
|
368
|
+
next if fields.nil? || fields.key?(:leftover)
|
|
369
|
+
|
|
370
|
+
year = fields[:year]
|
|
371
|
+
month = fields[:mon]
|
|
372
|
+
day = fields[:mday]
|
|
373
|
+
next unless year && month && day && Date.valid_civil?(year, month, day)
|
|
374
|
+
|
|
375
|
+
hour = fields[:hour] || 0
|
|
376
|
+
min = fields[:min] || 0
|
|
377
|
+
sec = fields[:sec] || 0
|
|
378
|
+
next unless hour < 24 && min < 60 && sec < 60
|
|
379
|
+
if pattern
|
|
380
|
+
# Inline, without a method call per date (see .date_format).
|
|
381
|
+
byte =
|
|
382
|
+
if anchor == 0 then string.getbyte(0)
|
|
383
|
+
elsif anchor.is_a?(Integer) then string.getbyte(-5) == anchor && string.getbyte(-4)
|
|
384
|
+
else anchor && (last = string.byterindex(anchor)) && string.getbyte(last + anchor.bytesize)
|
|
385
|
+
end
|
|
386
|
+
# A year text starting with "1".."9" (bytes 49..57) and a value of four digits has four digits.
|
|
387
|
+
next unless (byte && byte > 48 && byte < 58 && year > 999 && year < 10_000) || pattern.match?(string)
|
|
388
|
+
end
|
|
389
|
+
return [ year, month, day, hour, min, sec ]
|
|
390
|
+
end
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def boolean_text(str)
|
|
395
|
+
word = str.strip.downcase
|
|
396
|
+
if @true_words.include?(word) then true
|
|
397
|
+
elsif @false_words.include?(word) then false
|
|
398
|
+
else blank_or_invalid(:invalid_boolean, str)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# --- general numbers ----------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def general_number(text, type, pad)
|
|
405
|
+
case type
|
|
406
|
+
when nil then native_number(text)
|
|
407
|
+
when :decimal then parse_number(text)
|
|
408
|
+
when :integer then integer_number(text)
|
|
409
|
+
when :string then number_string(text, pad)
|
|
410
|
+
when :boolean then boolean_number(text)
|
|
411
|
+
else reject(type, text)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# BigDecimal from the cell text, rounded half up to 15 significant digits only when the text is the full print
|
|
416
|
+
# of a double: a fraction or an exponent with 16 or 17 significant digits. Integer text (digits and a sign) is
|
|
417
|
+
# exact, and so is text with more than 17 significant digits, which no double prints. The explicit mode keeps a
|
|
418
|
+
# BigDecimal: without it, BigDecimal#round returns an Integer for a negative digit count. BigDecimal#exponent is
|
|
419
|
+
# the power of ten of 0.ddd... x 10**exponent, so 15 significant digits are 15 - exponent decimals. A text of at
|
|
420
|
+
# most 15 bytes has at most 15 significant digits: it is never rounded.
|
|
421
|
+
def parse_number(text)
|
|
422
|
+
raise Failure.new(:invalid_decimal, text) unless NUMBER.match?(text)
|
|
423
|
+
|
|
424
|
+
number = BigDecimal(text)
|
|
425
|
+
if text.bytesize > SIGNIFICANT_DIGITS && DOUBLE_DIGITS.cover?(number.n_significant_digits) && !INTEGER.match?(text)
|
|
426
|
+
number = number.round(SIGNIFICANT_DIGITS - number.exponent, BigDecimal::ROUND_HALF_UP)
|
|
427
|
+
end
|
|
428
|
+
number
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# The fast path: integer text of at most 15 bytes reads as String#to_i, the Integer the BigDecimal below would
|
|
432
|
+
# give (far fewer digits than MAX_INTEGER_EXPONENT), without building it.
|
|
433
|
+
def native_number(text)
|
|
434
|
+
return text.to_i if text.bytesize <= SIGNIFICANT_DIGITS && INTEGER.match?(text)
|
|
435
|
+
|
|
436
|
+
number = parse_number(text)
|
|
437
|
+
if number.frac.zero?
|
|
438
|
+
number.exponent <= MAX_INTEGER_EXPONENT ? number.to_i : number
|
|
439
|
+
else
|
|
440
|
+
@float ? number.to_f : number
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Integer text is exact whatever its length (at most MAX_NUMBER_BYTES). A fraction fails with the number as read
|
|
445
|
+
# (its plain text, rounded like every stored number), not the full print of a double that a file may hold.
|
|
446
|
+
def integer_number(text)
|
|
447
|
+
raise Failure.new(:invalid_integer, text) unless NUMBER.match?(text)
|
|
448
|
+
return text.to_i if INTEGER.match?(text)
|
|
449
|
+
|
|
450
|
+
number = parse_number(text)
|
|
451
|
+
raise Failure.new(:not_integral, DecimalText.plain(number)) unless number.frac.zero?
|
|
452
|
+
|
|
453
|
+
integral(number, text)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# 1 and 0, whatever the configured words are.
|
|
457
|
+
def boolean_number(text)
|
|
458
|
+
raise Failure.new(:invalid_boolean, text) unless NUMBER.match?(text)
|
|
459
|
+
|
|
460
|
+
case native_number(text)
|
|
461
|
+
when 1 then true
|
|
462
|
+
when 0 then false
|
|
463
|
+
else raise Failure.new(:invalid_boolean, text)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def integral(number, raw)
|
|
468
|
+
raise Failure.new(:not_integral, raw) unless number.frac.zero?
|
|
469
|
+
raise Failure.new(:invalid_integer, raw) if number.exponent > MAX_INTEGER_EXPONENT
|
|
470
|
+
|
|
471
|
+
number.to_i
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Digits of at most 15 bytes are taken as written, without a BigDecimal; any other number as its plain text.
|
|
475
|
+
def number_string(text, pad)
|
|
476
|
+
string =
|
|
477
|
+
if text.bytesize <= SIGNIFICANT_DIGITS && DIGITS.match?(text)
|
|
478
|
+
text
|
|
479
|
+
else
|
|
480
|
+
DecimalText.plain(parse_number(text))
|
|
481
|
+
end
|
|
482
|
+
pad && DIGITS.match?(string) ? string.rjust(pad, "0") : string
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# --- serials ------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
# The serial as a whole number of units (seconds or milliseconds), rounded half up, with
|
|
488
|
+
# integer arithmetic only.
|
|
489
|
+
# @return [Integer, nil] nil when the text is not a number
|
|
490
|
+
def serial_units(text, per_day)
|
|
491
|
+
if SERIAL.match?(text)
|
|
492
|
+
dot = text.index(".")
|
|
493
|
+
return text.to_i * per_day unless dot
|
|
494
|
+
|
|
495
|
+
digits = text.bytesize - dot - 1
|
|
496
|
+
scale = 10**digits
|
|
497
|
+
text.to_i * per_day + (text[dot + 1, digits].to_i * per_day * 2 + scale) / (scale * 2)
|
|
498
|
+
elsif NUMBER.match?(text)
|
|
499
|
+
(Rational(text) * per_day + Rational(1, 2)).floor
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def date_serial(text, kind, type)
|
|
504
|
+
total = serial_units(text, SECONDS_PER_DAY)
|
|
505
|
+
days, seconds = total.divmod(SECONDS_PER_DAY) if total && total >= 0
|
|
506
|
+
date = days && ExcelSerial.date(days, date1904: @date1904)
|
|
507
|
+
raise Failure.new(FAILURES[type] || (kind == :date ? :invalid_date : :invalid_datetime), text) unless date
|
|
508
|
+
|
|
509
|
+
temporal(date, kind == :date ? 0 : seconds, kind, type, text)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def time_serial(text, type)
|
|
513
|
+
millis = serial_units(text, MILLIS_PER_DAY)
|
|
514
|
+
raise Failure.new(FAILURES[type] || :invalid_decimal, text) unless millis && millis >= 0
|
|
515
|
+
|
|
516
|
+
seconds_value(millis, type, text)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# --- shared conversions -------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
# A date, or a date with a time of day (seconds since midnight), as the requested type.
|
|
522
|
+
def temporal(date, seconds, kind, type, raw, zone = @zone)
|
|
523
|
+
case type
|
|
524
|
+
when nil then kind == :date ? date : time_of(date, seconds, zone)
|
|
525
|
+
when :date then date
|
|
526
|
+
when :datetime then time_of(date, seconds, zone)
|
|
527
|
+
when :string then kind == :date ? date.iso8601 : "#{date.iso8601}T#{clock(seconds)}"
|
|
528
|
+
else reject(type, raw)
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def time_of(date, seconds, zone = @zone)
|
|
533
|
+
hour, rest = seconds.divmod(3600)
|
|
534
|
+
min, sec = rest.divmod(60)
|
|
535
|
+
zone.local(date.year, date.month, date.day, hour, min, sec)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# A duration given in milliseconds as the requested type: whole seconds (Integer) when no
|
|
539
|
+
# fraction remains, BigDecimal (Float with decimals: :float) otherwise.
|
|
540
|
+
def seconds_value(millis, type, raw)
|
|
541
|
+
case type
|
|
542
|
+
when nil then (millis % 1000).zero? ? millis / 1000 : fractional_seconds(millis)
|
|
543
|
+
when :integer then (millis % 1000).zero? ? millis / 1000 : raise(Failure.new(:not_integral, raw))
|
|
544
|
+
when :decimal then BigDecimal(millis) / 1000
|
|
545
|
+
when :string then clock((millis + 500) / 1000)
|
|
546
|
+
else reject(type, raw)
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def fractional_seconds(millis)
|
|
551
|
+
@float ? millis / 1000.0 : BigDecimal(millis) / 1000
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def clock(seconds)
|
|
555
|
+
format("%02d:%02d:%02d", seconds / 3600, seconds % 3600 / 60, seconds % 60)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# The wall-clock date and seconds of the instant given by UTC +date+ and +seconds+ shifted by the offset of
|
|
559
|
+
# +match+ (nil for "Z"), in the zone that converts instants, and that zone.
|
|
560
|
+
def in_zone(date, seconds, match, text, type)
|
|
561
|
+
offset = 0
|
|
562
|
+
if match[9]
|
|
563
|
+
hours = match[10].to_i
|
|
564
|
+
minutes = match[11].to_i
|
|
565
|
+
return invalid_iso_date(text, type) unless hours < 24 && minutes < 60
|
|
566
|
+
|
|
567
|
+
offset = (hours * 3600 + minutes * 60) * (match[9] == "-" ? -1 : 1)
|
|
568
|
+
end
|
|
569
|
+
instant = Time.utc(date.year, date.month, date.day) + (seconds - offset)
|
|
570
|
+
year, month, day, hour, min, sec = TimeZone.wall_clock(@instant_zone, instant)
|
|
571
|
+
[ Date.new(year, month, day), hour * 3600 + min * 60 + sec, @instant_zone ]
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def invalid_iso_date(text, type)
|
|
575
|
+
raise Failure.new(FAILURES[type] || (text.include?("T") ? :invalid_datetime : :invalid_date), text)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|