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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cast"
4
+ require_relative "time_zone"
5
+
6
+ module Calcsheet
7
+ # Checks the type and value of every option when Configuration#options resolves them, so that a wrong value fails
8
+ # at the call that gives it (Calcsheet.open, Calcsheet.write, Calcsheet.stream, a read with overrides) or, for a
9
+ # configured value, at the next Calcsheet.open, Calcsheet.write or Calcsheet.stream (an open book keeps its snapshot),
10
+ # naming the option, instead of in the middle of a read or a write. The README options reference describes the
11
+ # accepted values; each method below raises ArgumentError "key: must be ..., got ..." for another one.
12
+ #
13
+ # @api private
14
+ module OptionValidation
15
+ # Limits where nil means none.
16
+ LIMITS = %i[max_file_size max_rows max_sheets max_cell_bytes max_rows_per_sheet].freeze
17
+ # The zip-bomb limits: always on, so that a blank configuration value cannot switch them off.
18
+ ZIP_LIMITS = %i[max_uncompressed_size max_zip_entries].freeze
19
+ # The options that are true or false.
20
+ BOOLEANS = %i[strip_formula_prefix excel_it sanitize_sheet_names].freeze
21
+ # The decimal separators of text read as numbers and of CSV output.
22
+ DECIMAL_SEPARATORS = [ ".", "," ].freeze
23
+ # The thousands separators: none, ".", ",", a space, an apostrophe, the no-break space U+00A0 and the narrow
24
+ # no-break space U+202F.
25
+ THOUSANDS_SEPARATORS = [ nil, ".", ",", " ", "'", " ", " " ].freeze
26
+ # The line endings of CSV output.
27
+ ROW_SEPARATORS = [ "\n", "\r\n", "\r" ].freeze
28
+ # Characters a CSV separator or quote cannot be: line breaks, and the double quote for the separator.
29
+ LINE_BREAKS = [ "\n", "\r" ].freeze
30
+ # A strptime directive of a date format, or a "%" that ends it.
31
+ DIRECTIVE = /%(.)|%\z/m
32
+
33
+ module_function
34
+
35
+ # @param options [Hash{Symbol => Object}] every key of Configuration::DEFAULTS
36
+ # @return [Hash] +options+
37
+ # @raise [ArgumentError] naming the first invalid option
38
+ def check!(options)
39
+ LIMITS.each { |key| limit(key, options[key]) }
40
+ ZIP_LIMITS.each { |key| positive(key, options[key]) }
41
+ ratio(options[:max_compression_ratio])
42
+ BOOLEANS.each { |key| boolean(key, options[key]) }
43
+ non_negative(:compression_ratio_floor, options[:compression_ratio_floor], nil_allowed: true)
44
+ positive(:batch_size, options[:batch_size])
45
+ non_negative(:sample_rows, options[:sample_rows], nil_allowed: false)
46
+ one_of(:decimals, options[:decimals], %i[bigdecimal float])
47
+ TimeZone.wrap(options[:time_zone])
48
+ separators(options[:decimal_separator], options[:thousands_separator])
49
+ %i[date_formats datetime_formats].each { |key| formats(key, options[key]) }
50
+ %i[true_words false_words].each { |key| strings(key, options[key]) }
51
+ csv(options)
52
+ options
53
+ end
54
+
55
+ # A positive Integer or nil.
56
+ def limit(key, value)
57
+ return if value.nil? || (value.is_a?(Integer) && value.positive?)
58
+
59
+ invalid(key, "a positive Integer or nil", value)
60
+ end
61
+
62
+ # max_compression_ratio: a positive finite real number, never nil.
63
+ def ratio(value)
64
+ return if value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
65
+
66
+ invalid(:max_compression_ratio, "a positive number", value)
67
+ end
68
+
69
+ # A positive Integer, never nil.
70
+ def positive(key, value)
71
+ invalid(key, "a positive Integer", value) unless value.is_a?(Integer) && value.positive?
72
+ end
73
+
74
+ # A non-negative Integer, or nil when +nil_allowed+.
75
+ def non_negative(key, value, nil_allowed:)
76
+ return if nil_allowed && value.nil?
77
+ return if value.is_a?(Integer) && !value.negative?
78
+
79
+ invalid(key, nil_allowed ? "a non-negative Integer or nil" : "a non-negative Integer", value)
80
+ end
81
+
82
+ # true or false, not truthy values.
83
+ def boolean(key, value)
84
+ invalid(key, "true or false", value) unless true.equal?(value) || false.equal?(value)
85
+ end
86
+
87
+ # A member of +allowed+.
88
+ def one_of(key, value, allowed)
89
+ invalid(key, "one of #{allowed.map(&:inspect).join(", ")}", value) unless allowed.include?(value)
90
+ end
91
+
92
+ # The decimal and thousands separators, which must differ.
93
+ def separators(decimal, thousands)
94
+ one_of(:decimal_separator, decimal, DECIMAL_SEPARATORS)
95
+ one_of(:thousands_separator, thousands, THOUSANDS_SEPARATORS)
96
+ return unless thousands == decimal
97
+
98
+ raise ArgumentError, "thousands_separator: must differ from decimal_separator, both are #{decimal.inspect}"
99
+ end
100
+
101
+ # strptime formats that use only the directives Cast reads (Cast::FORMAT_DIRECTIVES).
102
+ def formats(key, value)
103
+ strings(key, value)
104
+ value.each do |format|
105
+ format.scan(DIRECTIVE) do |(directive)|
106
+ next if Cast::FORMAT_DIRECTIVES.key?(directive)
107
+
108
+ raise ArgumentError, "#{key}: #{format.inspect} uses the unsupported directive %#{directive} " \
109
+ "(supported: #{Cast::FORMAT_DIRECTIVES.keys.map { "%#{_1}" }.join(" ")})"
110
+ end
111
+ end
112
+ end
113
+
114
+ # An Array of non-empty Strings.
115
+ def strings(key, value)
116
+ return if value.is_a?(Array) && value.all? { _1.is_a?(String) && !_1.empty? }
117
+
118
+ invalid(key, "an Array of non-empty Strings", value)
119
+ end
120
+
121
+ # The CSV options: the dialect forced when reading, and the writing ones.
122
+ def csv(options)
123
+ encoding(options[:encoding])
124
+ col_sep, quote_char = options.values_at(:col_sep, :quote_char)
125
+ character(:col_sep, col_sep, LINE_BREAKS + [ "\"" ])
126
+ character(:quote_char, quote_char, LINE_BREAKS)
127
+ raise ArgumentError, "col_sep: and quote_char: must differ, both are #{col_sep.inspect}" if col_sep && col_sep == quote_char
128
+
129
+ one_of(:row_sep, options[:row_sep], ROW_SEPARATORS)
130
+ one_of(:bom, options[:bom], [ nil, true, false ])
131
+ %i[date_format datetime_format].each do |key|
132
+ invalid(key, "a String or nil", options[key]) unless options[key].nil? || options[key].is_a?(String)
133
+ end
134
+ end
135
+
136
+ # nil, or an Encoding or the name of one Ruby knows (whether it can be read is checked by the CSV reader).
137
+ def encoding(value)
138
+ return if value.nil?
139
+
140
+ invalid(:encoding, "an Encoding, a String or nil", value) unless value.is_a?(Encoding) || value.is_a?(String)
141
+ Encoding.find(value)
142
+ rescue ArgumentError => error
143
+ raise if error.message.start_with?("encoding:")
144
+
145
+ raise ArgumentError, "encoding: unknown encoding #{value.inspect}"
146
+ end
147
+
148
+ # nil, or one valid character not in +refused+.
149
+ def character(key, value, refused)
150
+ return if value.nil? || (value.is_a?(String) && value.length == 1 && value.valid_encoding? && !refused.include?(value))
151
+
152
+ invalid(key, "nil or one character other than #{refused.map(&:inspect).join(", ")}", value)
153
+ end
154
+
155
+ # @raise [ArgumentError] naming +key+, what it must be and the value given
156
+ def invalid(key, expected, value)
157
+ raise ArgumentError, "#{key}: must be #{expected}, got #{value.inspect}"
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+
5
+ module Calcsheet
6
+ # Frozen value object holding the resolved options of one operation, built and checked by Configuration#options.
7
+ # Its members are the keys of Configuration::DEFAULTS, the single table to extend; each one is described in the
8
+ # options reference of the README. Book#options gives those of a book.
9
+ Options = Data.define(*Configuration::DEFAULTS.keys)
10
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # A non-blank row as a reader backend produces it (see Reading for the backend contract).
5
+ #
6
+ # @attr_reader number [Integer] the row number the user sees
7
+ # @attr_reader line [Integer, nil] the physical line where it starts (CSV); nil otherwise
8
+ # @attr_reader values [Array] in file order, already cast, with trailing blank cells trimmed
9
+ # @attr_reader errors [Array<Array(Integer, String, Symbol)>, nil] nil, or +[column_index, raw_value, reason]+ for
10
+ # each cell whose cast failed (its value is nil), in column order
11
+ RawRow = Data.define(:number, :line, :values, :errors) do
12
+ # @param line [Integer, nil] default nil
13
+ # @param errors [Array, nil] default nil
14
+ def initialize(number:, values:, line: nil, errors: nil)
15
+ super
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # The declared types a backend applies, by source column (see Reading for the backend contract).
5
+ #
6
+ # @attr_reader by_index [Array<Symbol, nil>] the type of each column; nil entries and indices beyond it fall back to
7
+ # +default+
8
+ # @attr_reader default [Symbol, nil] the type of every other column; nil for the native value
9
+ RawTypes = Data.define(:by_index, :default) do
10
+ # @return [Symbol, nil] the type of the column, nil for the native value
11
+ def [](index)
12
+ by_index[index] || default
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Calcsheet
6
+ module Readers
7
+ # Detects how a CSV file is written: its encoding, byte order mark, column separator and quote character.
8
+ # Only the encoding of a seekable source reads the whole file, and then only as a streaming byte-level
9
+ # UTF-8 check; the separator and the quote are decided on a sample.
10
+ #
11
+ # @api private
12
+ module CsvDialect
13
+ # The dialect of a CSV file.
14
+ #
15
+ # @attr_reader encoding [Encoding] the encoding of the file's bytes (UTF-8, UTF-16LE/BE, Windows-1252 or a
16
+ # forced one)
17
+ # @attr_reader bom_size [Integer] the bytes of the byte order mark to skip, 0 when there is none
18
+ # @attr_reader col_sep [String] the column separator
19
+ # @attr_reader quote_char [String] the quote character
20
+ # @attr_reader row_sep [String, Symbol] the first line ending outside quoted fields ("\n", "\r\n" or "\r"), or
21
+ # :auto when the sample has none and the parser decides
22
+ Dialect = Data.define(:encoding, :bom_size, :col_sep, :quote_char, :row_sep)
23
+
24
+ # The bytes of the sample that decides the separator, the quote, the line ending and a stream's encoding.
25
+ SAMPLE_BYTES = 64 * 1024
26
+ # The records (or lines) of the sample that decide.
27
+ SAMPLE_RECORDS = 50
28
+ # The bytes read at a time by the UTF-8 check of a whole file.
29
+ VALIDATION_CHUNK_SIZE = 64 * 1024
30
+ # The byte order marks and their encodings.
31
+ BOMS = [
32
+ [ "\xEF\xBB\xBF".b.freeze, Encoding::UTF_8 ],
33
+ [ "\xFF\xFE".b.freeze, Encoding::UTF_16LE ],
34
+ [ "\xFE\xFF".b.freeze, Encoding::UTF_16BE ]
35
+ ].freeze
36
+ # Candidates in tie-breaking order: the first one wins when several fit equally well.
37
+ SEPARATORS = [ ";", ",", "\t", "|" ].freeze
38
+ # The quote characters detection chooses from.
39
+ QUOTES = [ "\"", "'" ].freeze
40
+ # The separator of a sample that no candidate parses into records of more than one field.
41
+ DEFAULT_SEPARATOR = ","
42
+ # The quote character tried first, and kept unless the single quote is clearly better.
43
+ DEFAULT_QUOTE = "\""
44
+
45
+ module_function
46
+
47
+ # Detects the dialect of a CSV source. A seekable IO is read from its start and left at 0. A non-seekable
48
+ # one cannot be rewound: the sample (at most 64 KiB) is consumed, so the caller must buffer it or have
49
+ # copied the stream to a seekable one beforehand.
50
+ #
51
+ # @param io [IO] a binary-mode IO, positioned at 0 unless +seekable+ is true
52
+ # @param seekable [Boolean] whether the whole file may be validated and the IO rewound
53
+ # @param encoding [Encoding, String, nil] forces the encoding and skips its detection
54
+ # @param col_sep [String, nil] forces the separator
55
+ # @param quote_char [String, nil] forces the quote character
56
+ # @return [Dialect]
57
+ def detect(io, seekable:, encoding: nil, col_sep: nil, quote_char: nil)
58
+ io.seek(0, IO::SEEK_SET) if seekable
59
+ head = io.read(SAMPLE_BYTES).to_s.b
60
+ bom_encoding, bom_size = byte_order_mark(head)
61
+ encoding = encoding ? Encoding.find(encoding) : (bom_encoding || guess_encoding(io, head, seekable))
62
+ bom_size = 0 unless bom_encoding == encoding
63
+ text = sample_text(head, encoding, bom_size)
64
+ cut = head.bytesize >= SAMPLE_BYTES
65
+ col_sep, quote_char = detect_separator_and_quote(text, cut, col_sep, quote_char)
66
+ row_sep = row_separator(text, quote_char, cut)
67
+ Dialect.new(encoding: encoding, bom_size: bom_size, col_sep: col_sep, quote_char: quote_char, row_sep: row_sep)
68
+ ensure
69
+ io.seek(0, IO::SEEK_SET) if seekable
70
+ end
71
+
72
+ def byte_order_mark(head)
73
+ BOMS.each do |bom, encoding|
74
+ return [ encoding, bom.bytesize ] if head.start_with?(bom)
75
+ end
76
+ [ nil, 0 ]
77
+ end
78
+
79
+ # UTF-8 when the bytes are valid UTF-8, Windows-1252 otherwise. A seekable source is checked in full; a
80
+ # stream only on its sample.
81
+ def guess_encoding(io, head, seekable)
82
+ valid = seekable ? valid_utf8_file?(io) : valid_utf8_sample?(head)
83
+ valid ? Encoding::UTF_8 : Encoding::WINDOWS_1252
84
+ end
85
+
86
+ # Streams the file in fixed-size chunks read into one reused buffer, so that checking a large file allocates
87
+ # nothing per chunk. A multibyte sequence cut by a chunk boundary is not an error: the IO moves back to its
88
+ # start and the next chunk begins there.
89
+ def valid_utf8_file?(io)
90
+ io.seek(0, IO::SEEK_SET)
91
+ buffer = String.new(capacity: VALIDATION_CHUNK_SIZE)
92
+ while io.read(VALIDATION_CHUNK_SIZE, buffer) && !buffer.empty?
93
+ next if buffer.force_encoding(Encoding::UTF_8).valid_encoding?
94
+
95
+ body, tail = split_incomplete_tail(buffer)
96
+ return false unless body.force_encoding(Encoding::UTF_8).valid_encoding? && !tail.empty?
97
+ return false if io.eof?
98
+
99
+ io.seek(-tail.bytesize, IO::SEEK_CUR)
100
+ end
101
+ true
102
+ end
103
+
104
+ # The first 50 lines of the sample decide; a multibyte character cut by the end of a full sample is not
105
+ # an error.
106
+ def valid_utf8_sample?(head)
107
+ sample = first_lines(head)
108
+ sample = split_incomplete_tail(sample).first if head.bytesize >= SAMPLE_BYTES
109
+ utf8?(sample)
110
+ end
111
+
112
+ def first_lines(bytes)
113
+ position = 0
114
+ SAMPLE_RECORDS.times do
115
+ newline = bytes.index("\n", position)
116
+ return bytes unless newline
117
+
118
+ position = newline + 1
119
+ end
120
+ bytes.byteslice(0, position)
121
+ end
122
+
123
+ def utf8?(bytes)
124
+ bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
125
+ end
126
+
127
+ # @api private
128
+ # Splits off a trailing UTF-8 lead byte with fewer continuation bytes than its sequence needs (CsvInput reads
129
+ # in chunks with it).
130
+ #
131
+ # @return [Array(String, String)] the complete part and the incomplete tail (empty when none)
132
+ def split_incomplete_tail(bytes)
133
+ size = bytes.bytesize
134
+ 1.upto([ 3, size ].min) do |back|
135
+ byte = bytes.getbyte(size - back)
136
+ next if byte.between?(0x80, 0xBF)
137
+ return [ bytes.byteslice(0, size - back), bytes.byteslice(size - back, back) ] if sequence_length(byte) > back
138
+
139
+ break
140
+ end
141
+ [ bytes, "".b ]
142
+ end
143
+
144
+ def sequence_length(lead)
145
+ case lead
146
+ when 0xC2..0xDF then 2
147
+ when 0xE0..0xEF then 3
148
+ when 0xF0..0xF4 then 4
149
+ else 1
150
+ end
151
+ end
152
+
153
+ # The double quote is the default and wins ties: the single quote is chosen only when no separator parses the
154
+ # sample with a double quote (a failure that the sample's cut alone causes does not count) and the single
155
+ # quote parses it with consistent records of more than one field. A single quote is never inferred from
156
+ # records that a double quote reads as well, so that a value such as "Smith, John" in a one-column file stays
157
+ # one field.
158
+ def detect_separator_and_quote(text, cut, col_sep, quote_char)
159
+ return [ col_sep, quote_char ] if col_sep && quote_char
160
+
161
+ separators = col_sep ? [ col_sep ] : SEPARATORS
162
+ scores = scores(text, separators, quote_char || DEFAULT_QUOTE, cut)
163
+ best = best(scores)
164
+ if best.nil? && quote_char.nil?
165
+ best = best(scores(text, separators, "'", cut))
166
+ best = nil if best&.records&.zero?
167
+ end
168
+ best ||= scores.max_by(&:key)
169
+ return [ col_sep || DEFAULT_SEPARATOR, best.quote_char ] if best.records.zero?
170
+
171
+ [ best.col_sep, best.quote_char ]
172
+ end
173
+
174
+ # A candidate's parse of the sample. +records+ is the size of the largest group of the first SAMPLE_RECORDS
175
+ # records that have the same number of fields, more than one: the most consistent column count, not the most
176
+ # fields, so that a separator inside quoted fields does not mislead it. +failed+ is whether the parser rejected
177
+ # a record (see #field_counts).
178
+ #
179
+ # @attr col_sep [String] the candidate separator
180
+ # @attr quote_char [String] the candidate quote character
181
+ # @attr records [Integer] the size of the largest group of records with the same field count, above one
182
+ # @attr failed [Boolean] whether the parser rejected a record
183
+ Score = Struct.new(:col_sep, :quote_char, :records, :failed) do
184
+ # Ranking: the most consistent records, then the first separator in SEPARATORS.
185
+ # @return [Array(Integer, Integer)]
186
+ def key
187
+ [ records, -SEPARATORS.index(col_sep).to_i ]
188
+ end
189
+ end
190
+
191
+ # Each candidate parses the sample with the line ending that the quote character gives.
192
+ def scores(text, separators, quote_char, cut)
193
+ row_sep = row_separator(text, quote_char, cut)
194
+ separators.map { |col_sep| score(text, col_sep, quote_char, row_sep, cut) }
195
+ end
196
+
197
+ # The best candidate that parses the sample, nil when every one fails.
198
+ def best(scores)
199
+ scores.reject(&:failed).max_by(&:key)
200
+ end
201
+
202
+ def score(text, col_sep, quote_char, row_sep, cut)
203
+ counts, failed = field_counts(text, col_sep, quote_char, row_sep, cut)
204
+ records = counts.select { _1 > 1 }.tally.values.max || 0
205
+ Score.new(col_sep, quote_char, records, failed)
206
+ end
207
+
208
+ # Field count of each of the first 50 records, and whether the parser rejected a record. A quoted field
209
+ # still open at the end of a cut sample is not a failure: the cut made it, not the file.
210
+ def field_counts(text, col_sep, quote_char, row_sep, cut)
211
+ counts = []
212
+ csv = CSV.new(text, col_sep: col_sep, quote_char: quote_char, row_sep: row_sep)
213
+ while counts.size < SAMPLE_RECORDS && (row = csv.shift)
214
+ counts << row.size
215
+ end
216
+ [ counts, false ]
217
+ rescue CSV::MalformedCSVError => error
218
+ [ counts, !(cut && error.message.start_with?("Unclosed quoted field")) ]
219
+ end
220
+
221
+ # The first line ending outside quoted fields; :auto when there is none, or when it is a CR that ends a cut
222
+ # sample (the LF of a CRLF may follow beyond the cut).
223
+ def row_separator(text, quote_char, cut)
224
+ pattern = /[\r\n#{Regexp.escape(quote_char)}]/
225
+ quoted = false
226
+ position = 0
227
+ while (index = text.byteindex(pattern, position))
228
+ char = Regexp.last_match(0)
229
+ position = index + char.bytesize
230
+ if char == quote_char
231
+ quoted = !quoted
232
+ elsif !quoted
233
+ return "\n" if char == "\n"
234
+ return :auto if cut && position == text.bytesize
235
+
236
+ return text.getbyte(position) == 0x0A ? "\r\n" : "\r"
237
+ end
238
+ end
239
+ :auto
240
+ end
241
+
242
+ # The sample as UTF-8 text, without the BOM. A sample that is cut by its size limit loses its last,
243
+ # possibly incomplete line (and any character or code unit it cut).
244
+ def sample_text(head, encoding, bom_size)
245
+ text = to_utf8(head.byteslice(bom_size, head.bytesize - bom_size), encoding)
246
+ return text if head.bytesize < SAMPLE_BYTES
247
+
248
+ last_break = text.rindex(/[\r\n]/)
249
+ last_break ? text[0..last_break] : text
250
+ end
251
+
252
+ def to_utf8(bytes, encoding)
253
+ return bytes.dup.force_encoding(Encoding::UTF_8).scrub if encoding == Encoding::UTF_8
254
+
255
+ bytes = bytes.byteslice(0, bytes.bytesize & ~1) if [ Encoding::UTF_16LE, Encoding::UTF_16BE ].include?(encoding)
256
+ bytes.dup.force_encoding(encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
257
+ end
258
+
259
+ private_class_method :byte_order_mark, :guess_encoding, :valid_utf8_file?, :valid_utf8_sample?, :first_lines, :utf8?,
260
+ :sequence_length, :detect_separator_and_quote, :scores, :best, :score, :field_counts,
261
+ :row_separator, :sample_text, :to_utf8
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "csv_dialect"
4
+
5
+ module Calcsheet
6
+ module Readers
7
+ # The bytes of a CSV file as UTF-8 text for the stdlib CSV parser. It replays a prefix already read (the
8
+ # detection sample of a stream), transcodes UTF-16 and Windows-1252, and checks UTF-8, reading the IO in chunks
9
+ # with +read+ only. It answers what CSV::Parser uses: +gets(separator, limit)+, +eof?+ and the encodings.
10
+ #
11
+ # An invalid or undefined byte sequence raises its EncodingError only once the text before it has been read,
12
+ # so the parser fails on the record that holds it.
13
+ #
14
+ # @api private
15
+ class CsvInput
16
+ # The bytes read from the IO at a time.
17
+ CHUNK_SIZE = 64 * 1024
18
+
19
+ # @param io [IO] binary bytes, positioned after the byte order mark
20
+ # @param encoding [Encoding] the encoding of the bytes
21
+ # @param prefix [String] bytes read from +io+ already, returned first
22
+ # @param chunk_size [Integer] bytes read from +io+ at a time
23
+ def initialize(io, encoding, prefix: "".b, chunk_size: CHUNK_SIZE)
24
+ @io = io
25
+ @chunk_size = chunk_size
26
+ @converter = Encoding::Converter.new(encoding, Encoding::UTF_8) unless encoding == Encoding::UTF_8
27
+ @bytes = prefix.b
28
+ @text = String.new(encoding: Encoding::UTF_8)
29
+ @position = 0
30
+ @error = nil
31
+ @eof = false
32
+ end
33
+
34
+ # @return [Encoding] UTF-8, the encoding of the text handed out
35
+ def external_encoding
36
+ Encoding::UTF_8
37
+ end
38
+
39
+ # @return [nil] no transcoding by the parser
40
+ def internal_encoding
41
+ nil
42
+ end
43
+
44
+ # Like IO#gets: the text up to and including +separator+ (nil: no separator), at most +limit+ bytes (nil: no
45
+ # limit) rounded up to a whole character; nil at the end.
46
+ # @raise [EncodingError] the next bytes are not valid in the encoding
47
+ def gets(separator = $/, limit = nil)
48
+ length = line_length(separator, limit)
49
+ length = line_length(separator, limit) while length.nil? && fill
50
+ length ||= @text.bytesize - @position
51
+ if length.zero?
52
+ raise @error if @error
53
+
54
+ return nil
55
+ end
56
+ take(length)
57
+ end
58
+
59
+ # @return [Boolean] whether no text is left; false while an encoding error waits to be raised by #gets
60
+ def eof?
61
+ nil while @position == @text.bytesize && fill
62
+ @position == @text.bytesize && @error.nil?
63
+ end
64
+ alias eof eof?
65
+
66
+ private
67
+
68
+ def line_length(separator, limit)
69
+ stop = separator && @text.byteindex(separator, @position)
70
+ if stop
71
+ length = stop - @position + separator.bytesize
72
+ return limit && limit < length ? limit : length
73
+ end
74
+ limit if limit && @text.bytesize - @position >= limit
75
+ end
76
+
77
+ def take(length)
78
+ length += 1 while @position + length < @text.bytesize && @text.getbyte(@position + length).between?(0x80, 0xBF)
79
+ chunk = @text.byteslice(@position, length)
80
+ @position += length
81
+ chunk
82
+ end
83
+
84
+ # Decodes more bytes into the text. Returns false when nothing more can come: the end, or an invalid sequence.
85
+ def fill
86
+ return false if @error || @eof
87
+
88
+ chunk = @io.read(@chunk_size)
89
+ # A duck-typed read(length) may answer "" instead of nil at the end.
90
+ chunk.nil? || chunk.empty? ? @eof = true : @bytes << chunk.b
91
+ decoded = @converter ? convert : validate
92
+ @text = @text.byteslice(@position, @text.bytesize - @position) if @position.positive?
93
+ @position = 0
94
+ @text << decoded
95
+ true
96
+ end
97
+
98
+ # Transcodes the bytes, keeping an incomplete trailing sequence in the converter until the end.
99
+ def convert
100
+ output = String.new(encoding: Encoding::UTF_8)
101
+ result = @converter.primitive_convert(@bytes, output, nil, nil, @eof ? 0 : Encoding::Converter::PARTIAL_INPUT)
102
+ unless result == :source_buffer_empty || result == :finished
103
+ @error = @converter.last_error
104
+ @bytes.clear
105
+ end
106
+ output
107
+ end
108
+
109
+ # The valid UTF-8 prefix of the bytes; an incomplete trailing character waits for the next chunk.
110
+ def validate
111
+ body, @bytes = @eof ? [ @bytes, "".b ] : CsvDialect.split_incomplete_tail(@bytes)
112
+ text = body.force_encoding(Encoding::UTF_8)
113
+ return text if text.valid_encoding?
114
+
115
+ valid = 0
116
+ invalid = text.each_char.find do |char|
117
+ next true unless char.valid_encoding?
118
+
119
+ valid += char.bytesize
120
+ false
121
+ end
122
+ @error = Encoding::InvalidByteSequenceError.new("invalid UTF-8 byte sequence #{invalid.b.inspect}")
123
+ @bytes = "".b
124
+ text.byteslice(0, valid)
125
+ end
126
+ end
127
+ end
128
+ end