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,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
|