calcsheet 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +124 -9
- data/lib/calcsheet/book.rb +25 -9
- data/lib/calcsheet/cell_range.rb +54 -0
- data/lib/calcsheet/configuration.rb +10 -0
- data/lib/calcsheet/display_format.rb +75 -0
- data/lib/calcsheet/errors.rb +5 -0
- data/lib/calcsheet/formula.rb +40 -0
- data/lib/calcsheet/limited_inflater.rb +16 -0
- data/lib/calcsheet/native_backends.rb +139 -0
- data/lib/calcsheet/option_validation.rb +4 -1
- data/lib/calcsheet/raw_row.rb +1 -1
- data/lib/calcsheet/readers/csv_reader.rb +43 -20
- data/lib/calcsheet/readers/csv_records.rb +193 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
- data/lib/calcsheet/readers/ods/scanner.rb +33 -6
- data/lib/calcsheet/readers/ods_reader.rb +16 -1
- data/lib/calcsheet/readers/sax_sheet.rb +6 -48
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
- data/lib/calcsheet/reading.rb +29 -7
- data/lib/calcsheet/row.rb +2 -2
- data/lib/calcsheet/row_builder.rb +235 -0
- data/lib/calcsheet/sheet_list.rb +25 -0
- data/lib/calcsheet/sheet_selection.rb +3 -2
- data/lib/calcsheet/style.rb +114 -0
- data/lib/calcsheet/version.rb +1 -1
- data/lib/calcsheet/write_book.rb +49 -17
- data/lib/calcsheet/write_sheet.rb +220 -18
- data/lib/calcsheet/writers/csv_writer.rb +18 -7
- data/lib/calcsheet/writers/ods_writer.rb +249 -16
- data/lib/calcsheet/writers/open_formula.rb +67 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
- data/lib/calcsheet/writers/zip_buffered.rb +20 -1
- data/lib/calcsheet.rb +31 -8
- metadata +11 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
|
|
6
|
+
module Calcsheet
|
|
7
|
+
# The reader backends of the optional calcsheet-native gem, which Calcsheet.open uses in place of the Ruby ones
|
|
8
|
+
# under +backend: :auto+ (when calcsheet-native is installed and compatible) and +backend: :native+ (or raises).
|
|
9
|
+
#
|
|
10
|
+
# calcsheet-native is found once per process, at the first open that asks for it: Calcsheet::Native when it is
|
|
11
|
+
# already defined, else +require "calcsheet/native"+ (a LoadError for that library itself means not installed; one
|
|
12
|
+
# for a file it requires, such as its extension, means it is unusable). Its +Calcsheet::Native::PROTOCOL+ must be an Integer of PROTOCOLS and +Calcsheet::Native.backends+ a
|
|
13
|
+
# Hash mapping formats among FORMATS to classes implementing the backend contract of Reading; otherwise, or when its
|
|
14
|
+
# code raises, it is unusable: the first +:auto+ open warns once and the Ruby backends read. A native class replaces
|
|
15
|
+
# only calcsheet's own reader: when an application registered another one in Calcsheet.backends for that format,
|
|
16
|
+
# the application's one reads.
|
|
17
|
+
#
|
|
18
|
+
# calcsheet-native depends on this interface (the protocol, +Calcsheet::Native.backends+ and the backend contract of
|
|
19
|
+
# Reading): change it only in a minor release and together with the protocol version.
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
module NativeBackends
|
|
23
|
+
# The protocols of calcsheet-native this calcsheet supports.
|
|
24
|
+
PROTOCOLS = [ 1 ].freeze
|
|
25
|
+
# The library of calcsheet-native.
|
|
26
|
+
LIBRARY = "calcsheet/native"
|
|
27
|
+
# The formats calcsheet-native may read.
|
|
28
|
+
FORMATS = %i[csv xlsx ods].freeze
|
|
29
|
+
# What an open from the code of calcsheet-native finds while that code is being required: nothing yet.
|
|
30
|
+
LOADING = [ {}.freeze, "calcsheet-native is being loaded (an open from its own code)", nil ].freeze
|
|
31
|
+
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@library = LIBRARY
|
|
34
|
+
@loaded = nil
|
|
35
|
+
@warning = nil
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @param format [Symbol] a key of Calcsheet.backends
|
|
39
|
+
# @param mode [Symbol] +:auto+ or +:native+
|
|
40
|
+
# @return [Class, nil] the native backend of +format+; nil when none applies under +:auto+
|
|
41
|
+
# @raise [BackendUnavailableError] under +:native+ when none applies
|
|
42
|
+
def backend_for(format, mode)
|
|
43
|
+
backends, reason, cause = loaded
|
|
44
|
+
reason ||= refusal(backends, format)
|
|
45
|
+
return backends.fetch(format) if reason.nil?
|
|
46
|
+
raise BackendUnavailableError, "backend: :native: #{reason}", cause: cause if mode == :native
|
|
47
|
+
|
|
48
|
+
warn_once
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# For specs: forgets what was loaded, so that the next open looks for calcsheet-native again, requiring
|
|
53
|
+
# +library+ if Calcsheet::Native is not defined.
|
|
54
|
+
# @param library [String]
|
|
55
|
+
# @return [void]
|
|
56
|
+
def reset!(library: LIBRARY)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@library = library
|
|
59
|
+
@loaded = nil
|
|
60
|
+
@warning = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# @return [Array(Hash{Symbol => Class}, String, Exception)] the native backends by format and, when
|
|
67
|
+
# calcsheet-native cannot be used, the reason (its backends are then empty) and the error that made it
|
|
68
|
+
# unusable, if any
|
|
69
|
+
def loaded
|
|
70
|
+
return @loaded if @loaded
|
|
71
|
+
# The thread that is requiring calcsheet-native opens a book from its code: the lock cannot be taken again,
|
|
72
|
+
# and nothing is known yet (nor memoized).
|
|
73
|
+
return LOADING if @mutex.owned?
|
|
74
|
+
|
|
75
|
+
@mutex.synchronize { @loaded ||= load_native }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Any error of calcsheet-native's code (its extension's initialization, a NameError, a failing backends) makes
|
|
79
|
+
# it unusable, once: it is not required again at the next open.
|
|
80
|
+
def load_native
|
|
81
|
+
native = native_module
|
|
82
|
+
return [ {}.freeze, "calcsheet-native is not installed (add it to the Gemfile)", nil ].freeze if native.nil?
|
|
83
|
+
|
|
84
|
+
# A Calcsheet::Native without an Integer PROTOCOL, or without backends, is not one this calcsheet can use.
|
|
85
|
+
protocol = native.const_get(:PROTOCOL, false) if native.const_defined?(:PROTOCOL, false)
|
|
86
|
+
unless protocol.is_a?(Integer) && PROTOCOLS.include?(protocol)
|
|
87
|
+
return unusable("calcsheet-native protocol #{protocol.inspect} is not supported by calcsheet #{VERSION} " \
|
|
88
|
+
"(supported: #{PROTOCOLS.join(", ")})")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
backends = native.backends if native.respond_to?(:backends)
|
|
92
|
+
unless backends.is_a?(Hash)
|
|
93
|
+
return unusable("calcsheet-native protocol #{protocol} does not answer Calcsheet::Native.backends with a Hash")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
[ backends.slice(*FORMATS).freeze, nil, nil ].freeze
|
|
97
|
+
rescue StandardError, ScriptError => error # a LoadError for the library itself is not an error (#native_module)
|
|
98
|
+
unusable("calcsheet-native cannot be loaded (#{error.class}: #{error.message})", error)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Array(Hash, String, Exception)] no backends, +reason+ (which the first +:auto+ open warns of) and
|
|
102
|
+
# +cause+
|
|
103
|
+
def unusable(reason, cause = nil)
|
|
104
|
+
@warning = "calcsheet: #{reason}; using the Ruby backends"
|
|
105
|
+
[ {}.freeze, reason, cause ].freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Module, nil] Calcsheet::Native; nil when the library itself cannot be found (calcsheet-native is not
|
|
109
|
+
# installed) or defines no Calcsheet::Native
|
|
110
|
+
# @raise [LoadError] when the library is found but cannot be loaded (its extension or a dependency is missing)
|
|
111
|
+
def native_module
|
|
112
|
+
require @library unless Calcsheet.const_defined?(:Native, false)
|
|
113
|
+
Calcsheet::Native if Calcsheet.const_defined?(:Native, false)
|
|
114
|
+
rescue LoadError => error
|
|
115
|
+
raise unless error.path == @library # a file the library requires is missing: it is installed but unusable
|
|
116
|
+
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [String, nil] why the loaded calcsheet-native does not read +format+; nil when it does
|
|
121
|
+
def refusal(backends, format)
|
|
122
|
+
return "calcsheet-native does not read #{format}" unless backends.key?(format)
|
|
123
|
+
return if Calcsheet.backends[format].equal?(RUBY_BACKENDS[format])
|
|
124
|
+
|
|
125
|
+
"Calcsheet.backends[#{format.inspect}] is not the reader calcsheet-native replaces"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Warns of an unusable calcsheet-native, at the first open that falls back to the Ruby backends.
|
|
129
|
+
def warn_once
|
|
130
|
+
return if @warning.nil?
|
|
131
|
+
|
|
132
|
+
@mutex.synchronize do
|
|
133
|
+
warn(@warning) if @warning # another thread may have warned meanwhile
|
|
134
|
+
@warning = nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -17,7 +17,9 @@ module Calcsheet
|
|
|
17
17
|
# The zip-bomb limits: always on, so that a blank configuration value cannot switch them off.
|
|
18
18
|
ZIP_LIMITS = %i[max_uncompressed_size max_zip_entries].freeze
|
|
19
19
|
# The options that are true or false.
|
|
20
|
-
BOOLEANS = %i[strip_formula_prefix excel_it sanitize_sheet_names].freeze
|
|
20
|
+
BOOLEANS = %i[strip_formula_prefix excel_it sanitize_sheet_names precheck_sheets].freeze
|
|
21
|
+
# The values of +backend:+.
|
|
22
|
+
BACKENDS = %i[auto ruby native].freeze
|
|
21
23
|
# The decimal separators of text read as numbers and of CSV output.
|
|
22
24
|
DECIMAL_SEPARATORS = [ ".", "," ].freeze
|
|
23
25
|
# The thousands separators: none, ".", ",", a space, an apostrophe, the no-break space U+00A0 and the narrow
|
|
@@ -44,6 +46,7 @@ module Calcsheet
|
|
|
44
46
|
positive(:batch_size, options[:batch_size])
|
|
45
47
|
non_negative(:sample_rows, options[:sample_rows], nil_allowed: false)
|
|
46
48
|
one_of(:decimals, options[:decimals], %i[bigdecimal float])
|
|
49
|
+
one_of(:backend, options[:backend], BACKENDS)
|
|
47
50
|
TimeZone.wrap(options[:time_zone])
|
|
48
51
|
separators(options[:decimal_separator], options[:thousands_separator])
|
|
49
52
|
%i[date_formats datetime_formats].each { |key| formats(key, options[key]) }
|
data/lib/calcsheet/raw_row.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Calcsheet
|
|
|
5
5
|
#
|
|
6
6
|
# @attr_reader number [Integer] the row number the user sees
|
|
7
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,
|
|
8
|
+
# @attr_reader values [Array] in file order, already cast, trimmed at the end (see Reading)
|
|
9
9
|
# @attr_reader errors [Array<Array(Integer, String, Symbol)>, nil] nil, or +[column_index, raw_value, reason]+ for
|
|
10
10
|
# each cell whose cast failed (its value is nil), in column order
|
|
11
11
|
RawRow = Data.define(:number, :line, :values, :errors) do
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "csv"
|
|
4
4
|
require "stringio"
|
|
5
|
-
require_relative "../cast"
|
|
6
5
|
require_relative "../errors"
|
|
7
6
|
require_relative "../formula_protection"
|
|
8
|
-
require_relative "../raw_row"
|
|
9
7
|
require_relative "../reading"
|
|
8
|
+
require_relative "../row_builder"
|
|
10
9
|
require_relative "../sheet_info"
|
|
11
10
|
require_relative "csv_dialect"
|
|
12
11
|
require_relative "csv_input"
|
|
12
|
+
require_relative "csv_records"
|
|
13
13
|
require_relative "row_batches"
|
|
14
14
|
|
|
15
15
|
module Calcsheet
|
|
@@ -20,7 +20,8 @@ module Calcsheet
|
|
|
20
20
|
# @api private
|
|
21
21
|
module Readers
|
|
22
22
|
# The CSV reader backend (see Reading for the contract): one sheet, named after the file without its extension
|
|
23
|
-
# ("csv" for an IO without a path), parsed by the stdlib CSV
|
|
23
|
+
# ("csv" for an IO without a path), parsed in the detected dialect by CsvRecords, or by the stdlib CSV when the
|
|
24
|
+
# sample showed no line ending.
|
|
24
25
|
#
|
|
25
26
|
# Rows are numbered by CSV record, counting the header and blank lines, and +line+ is the physical line where
|
|
26
27
|
# the record starts. A seekable source is read again from its start whenever a request needs a record
|
|
@@ -78,6 +79,13 @@ module Calcsheet
|
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
# Drops the parser and the sample; the book closes the source.
|
|
82
|
+
# The row that would follow the last record, once a read reached the end of the file: the blank records at the
|
|
83
|
+
# end are rows of the file (see Reading).
|
|
84
|
+
# @return [Array(Integer, Integer)] its number and line
|
|
85
|
+
def end_row(_sheet_index)
|
|
86
|
+
[ @records + 1, @line ]
|
|
87
|
+
end
|
|
88
|
+
|
|
81
89
|
def close
|
|
82
90
|
@csv = nil
|
|
83
91
|
@rows = nil
|
|
@@ -95,6 +103,7 @@ module Calcsheet
|
|
|
95
103
|
# forced dialect or a parser that failed starts again from the first record.
|
|
96
104
|
restart(forced) if @csv.nil? || start <= @records || forced != @forced
|
|
97
105
|
strip = cast.options.strip_formula_prefix
|
|
106
|
+
builder = RowBuilder.new(cast, types)
|
|
98
107
|
while (fields = shift)
|
|
99
108
|
number = @records += 1
|
|
100
109
|
line = @line
|
|
@@ -108,8 +117,7 @@ module Calcsheet
|
|
|
108
117
|
last -= 1 while last >= 0 && blank_field?(fields[last], number, last)
|
|
109
118
|
next if last.negative?
|
|
110
119
|
|
|
111
|
-
|
|
112
|
-
yield raw_row(number, line, fields, cast, types, strip)
|
|
120
|
+
yield raw_row(builder, number, line, fields, last, strip)
|
|
113
121
|
end
|
|
114
122
|
end
|
|
115
123
|
|
|
@@ -125,13 +133,24 @@ module Calcsheet
|
|
|
125
133
|
@forced = forced
|
|
126
134
|
end
|
|
127
135
|
@started = true
|
|
128
|
-
@csv =
|
|
136
|
+
@csv = records(input)
|
|
129
137
|
@stray = nil
|
|
130
138
|
@rows = nil
|
|
131
139
|
@records = 0
|
|
132
140
|
@line = 1
|
|
133
141
|
end
|
|
134
142
|
|
|
143
|
+
# The parser of the records: CsvRecords for a line ending the sample showed, the stdlib parser, which finds the
|
|
144
|
+
# line ending itself, otherwise.
|
|
145
|
+
def records(input)
|
|
146
|
+
dialect = @dialect
|
|
147
|
+
if dialect.row_sep.is_a?(String)
|
|
148
|
+
CsvRecords.new(input, col_sep: dialect.col_sep, quote_char: dialect.quote_char, row_sep: dialect.row_sep)
|
|
149
|
+
else
|
|
150
|
+
CSV.new(input, col_sep: dialect.col_sep, quote_char: dialect.quote_char, row_sep: dialect.row_sep)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
135
154
|
# The text the parser reads: the source after the byte order mark, or the sample (consumed here) and the rest of
|
|
136
155
|
# the stream.
|
|
137
156
|
def input
|
|
@@ -204,25 +223,29 @@ module Calcsheet
|
|
|
204
223
|
raise ArgumentError, "encoding: cannot read #{value.inspect} (#{error.message})"
|
|
205
224
|
end
|
|
206
225
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
226
|
+
# The row of a record whose last non-blank field is at +last+: the builder casts the fields, so that the row
|
|
227
|
+
# equals those of the other readers. A field is checked and unprotected first, when a limit or
|
|
228
|
+
# +strip_formula_prefix+ asks for it.
|
|
229
|
+
def raw_row(builder, number, line, fields, last, strip)
|
|
230
|
+
prepare(fields, last, number, strip) if strip || @max_cell_bytes
|
|
231
|
+
builder.text_row(fields, last, number, line: line)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Checks the size of the fields up to +last+ and removes the formula protection from them, in place.
|
|
235
|
+
def prepare(fields, last, number, strip)
|
|
236
|
+
index = 0
|
|
237
|
+
while index <= last
|
|
211
238
|
field = fields[index]
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
rescue Cast::Failure => failure
|
|
218
|
-
(errors ||= []) << [ index, field, failure.reason ]
|
|
219
|
-
nil
|
|
239
|
+
unless field.nil?
|
|
240
|
+
check_size(field, number, index) if @max_cell_bytes && field.bytesize > @max_cell_bytes
|
|
241
|
+
fields[index] = FormulaProtection.unprotect(field) if strip
|
|
242
|
+
end
|
|
243
|
+
index += 1
|
|
220
244
|
end
|
|
221
|
-
RawRow.new(number: number, line: line, values: values, errors: errors)
|
|
222
245
|
end
|
|
223
246
|
|
|
224
247
|
# Every field is measured before a regexp sees it: here those from the end to the last non-blank one, the
|
|
225
|
-
# others in
|
|
248
|
+
# others in #prepare.
|
|
226
249
|
def blank_field?(field, number, index)
|
|
227
250
|
return true if field.nil?
|
|
228
251
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
module Readers
|
|
7
|
+
# The records of a CSV text, as the stdlib CSV parser gives them with +liberal_parsing: false+, for a known line
|
|
8
|
+
# ending: the same fields (nil for an empty unquoted one, "" for an empty quoted one), the same records ([] for an
|
|
9
|
+
# empty line) and a CSV::MalformedCSVError with the same message where it refuses a record. The stdlib parser is
|
|
10
|
+
# general (converters, headers, skipped lines...) and costs about half of a CSV read; a record without the quote
|
|
11
|
+
# character is split in one call here, and one with it is scanned field by field.
|
|
12
|
+
#
|
|
13
|
+
# It stands in for the CSV object of CsvReader: #each gives an object whose #next is the next record, #line the
|
|
14
|
+
# raw text of the record #next returned, #row_sep the line ending.
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
class CsvRecords
|
|
18
|
+
# The text read from the input at a time, so that a long record is gathered in linear time.
|
|
19
|
+
CHUNK_BYTES = 64 * 1024
|
|
20
|
+
|
|
21
|
+
# @return [String] the line ending
|
|
22
|
+
attr_reader :row_sep
|
|
23
|
+
# @return [String, nil] the raw text of the last record, line ending included
|
|
24
|
+
attr_reader :line
|
|
25
|
+
|
|
26
|
+
# @param input [#gets] UTF-8 text (CsvInput)
|
|
27
|
+
# @param col_sep [String] the separator
|
|
28
|
+
# @param quote_char [String] the quote character
|
|
29
|
+
# @param row_sep [String] "\n", "\r\n" or "\r"
|
|
30
|
+
def initialize(input, col_sep:, quote_char:, row_sep:)
|
|
31
|
+
@input = input
|
|
32
|
+
@col_sep = col_sep
|
|
33
|
+
@quote = quote_char
|
|
34
|
+
@row_sep = row_sep
|
|
35
|
+
# The line break that is not the line ending, which an unquoted field keeps (none in a CRLF text).
|
|
36
|
+
@other_break = { "\n" => "\r", "\r" => "\n" }[row_sep]
|
|
37
|
+
@records = 0
|
|
38
|
+
@line = nil
|
|
39
|
+
@eof = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [CsvRecords] self: #next gives the records
|
|
43
|
+
def each
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<String, nil>] the fields of the next record
|
|
48
|
+
# @raise [StopIteration] at the end
|
|
49
|
+
# @raise [CSV::MalformedCSVError] for a record the stdlib parser refuses
|
|
50
|
+
# @raise [EncodingError] from the input
|
|
51
|
+
def next
|
|
52
|
+
text = read_line or raise StopIteration
|
|
53
|
+
fields = plain(text) || quoted(text)
|
|
54
|
+
@records += 1
|
|
55
|
+
fields
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# The next line, ending with the line ending unless the text ends first; nil at the end.
|
|
61
|
+
def read_line
|
|
62
|
+
return if @eof
|
|
63
|
+
|
|
64
|
+
line = @input.gets(@row_sep, CHUNK_BYTES)
|
|
65
|
+
return @eof = nil if line.nil?
|
|
66
|
+
|
|
67
|
+
until line.end_with?(@row_sep)
|
|
68
|
+
more = @input.gets(@row_sep, CHUNK_BYTES)
|
|
69
|
+
if more.nil?
|
|
70
|
+
@eof = true
|
|
71
|
+
break
|
|
72
|
+
end
|
|
73
|
+
line << more
|
|
74
|
+
end
|
|
75
|
+
line
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The fields of a line without the quote character or a line break inside it (but the other line break, which
|
|
79
|
+
# an unquoted field keeps): a split. nil for another line.
|
|
80
|
+
def plain(text)
|
|
81
|
+
return if text.include?(@quote)
|
|
82
|
+
|
|
83
|
+
body = text.end_with?(@row_sep) ? text.byteslice(0, text.bytesize - @row_sep.bytesize) : text
|
|
84
|
+
return if @other_break.nil? ? body.include?("\r") || body.include?("\n") : body.include?(@row_sep)
|
|
85
|
+
|
|
86
|
+
@line = text
|
|
87
|
+
fields = body.split(@col_sep, -1)
|
|
88
|
+
fields.map! { _1.empty? ? nil : _1 }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The fields of a record that holds the quote character or a stray line break, scanned field by field, reading
|
|
92
|
+
# further lines while a quoted field is open.
|
|
93
|
+
def quoted(text)
|
|
94
|
+
loop do
|
|
95
|
+
result = parse(text)
|
|
96
|
+
case result
|
|
97
|
+
when Array
|
|
98
|
+
@line = text
|
|
99
|
+
return result
|
|
100
|
+
when String then raise CSV::MalformedCSVError.new(result, @records + 1)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
more = read_line
|
|
104
|
+
return finish_unclosed(text) if more.nil?
|
|
105
|
+
|
|
106
|
+
text << more
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A quoted field still open at the end of the text.
|
|
111
|
+
def finish_unclosed(text)
|
|
112
|
+
@eof = true
|
|
113
|
+
result = parse(text, at_end: true)
|
|
114
|
+
raise CSV::MalformedCSVError.new(result, @records + 1) if result.is_a?(String)
|
|
115
|
+
|
|
116
|
+
@line = text
|
|
117
|
+
result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The record that +text+ holds: its fields, the stdlib's message for a malformed one, or :more when a quoted
|
|
121
|
+
# field is still open and the text may go on. The text ends with the line ending, or is the end of the input.
|
|
122
|
+
def parse(text, at_end: @eof)
|
|
123
|
+
size = text.bytesize
|
|
124
|
+
ending = text.end_with?(@row_sep) ? size - @row_sep.bytesize : size
|
|
125
|
+
fields = []
|
|
126
|
+
position = 0
|
|
127
|
+
return fields if ending.zero? && size.positive?
|
|
128
|
+
|
|
129
|
+
loop do
|
|
130
|
+
if at?(text, position, @quote)
|
|
131
|
+
close = position + @quote.bytesize
|
|
132
|
+
value = nil
|
|
133
|
+
loop do
|
|
134
|
+
found = text.byteindex(@quote, close)
|
|
135
|
+
return at_end ? "Unclosed quoted field" : :more if found.nil?
|
|
136
|
+
|
|
137
|
+
piece = text.byteslice(close, found - close)
|
|
138
|
+
after = found + @quote.bytesize
|
|
139
|
+
if at?(text, after, @quote)
|
|
140
|
+
# A doubled quote: the field is joined from its pieces.
|
|
141
|
+
(value ||= +"") << piece << @quote
|
|
142
|
+
close = after + @quote.bytesize
|
|
143
|
+
next
|
|
144
|
+
end
|
|
145
|
+
# The closing quote: what follows it must end the field or the record.
|
|
146
|
+
fields << (value ? value << piece : piece)
|
|
147
|
+
return fields if after == size || after == ending
|
|
148
|
+
return "Any value after quoted field isn't allowed" unless at?(text, after, @col_sep)
|
|
149
|
+
|
|
150
|
+
position = after + @col_sep.bytesize
|
|
151
|
+
break
|
|
152
|
+
end
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
stop = text.byteindex(@col_sep, position)
|
|
157
|
+
stop = ending if stop.nil? || stop > ending
|
|
158
|
+
field = text.byteslice(position, stop - position)
|
|
159
|
+
if (message = refusal(field))
|
|
160
|
+
return message
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
fields << (field.empty? ? nil : field)
|
|
164
|
+
return fields if stop == ending
|
|
165
|
+
|
|
166
|
+
position = stop + @col_sep.bytesize
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Whether +token+ is at +position+ of +text+; a one-byte token (the usual separator and quote) is compared as a
|
|
171
|
+
# byte, without a slice.
|
|
172
|
+
def at?(text, position, token)
|
|
173
|
+
return text.getbyte(position) == token.getbyte(0) if token.bytesize == 1
|
|
174
|
+
|
|
175
|
+
text.byteslice(position, token.bytesize) == token
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The stdlib's message for an unquoted field it refuses, by the first character it stops at: a quote, or in a
|
|
179
|
+
# CRLF text a line break of another ending (named a wrong line ending when it starts the field).
|
|
180
|
+
def refusal(field)
|
|
181
|
+
quote = field.index(@quote)
|
|
182
|
+
line_break = field.index(/[\r\n]/) if @other_break.nil?
|
|
183
|
+
return if quote.nil? && line_break.nil?
|
|
184
|
+
return "Illegal quoting" if line_break.nil? || (quote && quote < line_break)
|
|
185
|
+
|
|
186
|
+
shown = field[line_break] == "\n" ? "\\n" : "\\r"
|
|
187
|
+
return %(New line must be <"\\r\\n"> not <"#{shown}">) if line_break.zero?
|
|
188
|
+
|
|
189
|
+
%(Unquoted fields do not allow new line <"#{shown}">)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|