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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +62 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +921 -0
  6. data/lib/calcsheet/book.rb +164 -0
  7. data/lib/calcsheet/cast.rb +578 -0
  8. data/lib/calcsheet/column.rb +117 -0
  9. data/lib/calcsheet/column_letter.rb +34 -0
  10. data/lib/calcsheet/configuration.rb +186 -0
  11. data/lib/calcsheet/decimal_text.rb +19 -0
  12. data/lib/calcsheet/entry_name.rb +28 -0
  13. data/lib/calcsheet/errors.rb +186 -0
  14. data/lib/calcsheet/excel_serial.rb +65 -0
  15. data/lib/calcsheet/format_detector.rb +109 -0
  16. data/lib/calcsheet/formula_protection.rb +31 -0
  17. data/lib/calcsheet/grid.rb +20 -0
  18. data/lib/calcsheet/header_keys.rb +66 -0
  19. data/lib/calcsheet/limited_inflater.rb +326 -0
  20. data/lib/calcsheet/merge_check.rb +41 -0
  21. data/lib/calcsheet/odf.rb +45 -0
  22. data/lib/calcsheet/option_validation.rb +160 -0
  23. data/lib/calcsheet/options.rb +10 -0
  24. data/lib/calcsheet/raw_row.rb +18 -0
  25. data/lib/calcsheet/raw_types.rb +15 -0
  26. data/lib/calcsheet/readers/csv_dialect.rb +264 -0
  27. data/lib/calcsheet/readers/csv_input.rb +128 -0
  28. data/lib/calcsheet/readers/csv_reader.rb +239 -0
  29. data/lib/calcsheet/readers/ods/cell_formats.rb +115 -0
  30. data/lib/calcsheet/readers/ods/content_parser.rb +594 -0
  31. data/lib/calcsheet/readers/ods/count.rb +30 -0
  32. data/lib/calcsheet/readers/ods/cursor.rb +121 -0
  33. data/lib/calcsheet/readers/ods/patterns.rb +70 -0
  34. data/lib/calcsheet/readers/ods/row_seek.rb +157 -0
  35. data/lib/calcsheet/readers/ods/scanner.rb +302 -0
  36. data/lib/calcsheet/readers/ods/style_scan.rb +108 -0
  37. data/lib/calcsheet/readers/ods/tag_text.rb +76 -0
  38. data/lib/calcsheet/readers/ods_reader.rb +71 -0
  39. data/lib/calcsheet/readers/ox_errors.rb +40 -0
  40. data/lib/calcsheet/readers/references.rb +62 -0
  41. data/lib/calcsheet/readers/row_batches.rb +45 -0
  42. data/lib/calcsheet/readers/sax_sheet.rb +108 -0
  43. data/lib/calcsheet/readers/stream_remainder.rb +66 -0
  44. data/lib/calcsheet/readers/tag_scan.rb +119 -0
  45. data/lib/calcsheet/readers/xlsx/escapes.rb +36 -0
  46. data/lib/calcsheet/readers/xlsx/local_name.rb +54 -0
  47. data/lib/calcsheet/readers/xlsx/number_format.rb +89 -0
  48. data/lib/calcsheet/readers/xlsx/row_skipper.rb +162 -0
  49. data/lib/calcsheet/readers/xlsx/shared_strings.rb +209 -0
  50. data/lib/calcsheet/readers/xlsx/sheet_parser.rb +435 -0
  51. data/lib/calcsheet/readers/xlsx/styles.rb +156 -0
  52. data/lib/calcsheet/readers/xlsx/workbook_meta.rb +228 -0
  53. data/lib/calcsheet/readers/xlsx_reader.rb +122 -0
  54. data/lib/calcsheet/reading.rb +281 -0
  55. data/lib/calcsheet/row.rb +110 -0
  56. data/lib/calcsheet/sample.rb +10 -0
  57. data/lib/calcsheet/schema.rb +140 -0
  58. data/lib/calcsheet/sheet_info.rb +21 -0
  59. data/lib/calcsheet/sheet_names.rb +114 -0
  60. data/lib/calcsheet/sheet_selection.rb +58 -0
  61. data/lib/calcsheet/source.rb +263 -0
  62. data/lib/calcsheet/stream_body.rb +81 -0
  63. data/lib/calcsheet/time_zone.rb +96 -0
  64. data/lib/calcsheet/untrusted.rb +32 -0
  65. data/lib/calcsheet/version.rb +6 -0
  66. data/lib/calcsheet/write_book.rb +202 -0
  67. data/lib/calcsheet/write_sheet.rb +282 -0
  68. data/lib/calcsheet/writers/csv_writer.rb +138 -0
  69. data/lib/calcsheet/writers/ods_writer.rb +437 -0
  70. data/lib/calcsheet/writers/xlsx_writer.rb +350 -0
  71. data/lib/calcsheet/writers/xml_text.rb +35 -0
  72. data/lib/calcsheet/writers/zip_buffered.rb +55 -0
  73. data/lib/calcsheet/zip_container.rb +190 -0
  74. data/lib/calcsheet/zip_directory.rb +202 -0
  75. data/lib/calcsheet/zip_end_record.rb +117 -0
  76. data/lib/calcsheet.rb +295 -0
  77. 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