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,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "stringio"
5
+ require_relative "../cast"
6
+ require_relative "../errors"
7
+ require_relative "../formula_protection"
8
+ require_relative "../raw_row"
9
+ require_relative "../reading"
10
+ require_relative "../sheet_info"
11
+ require_relative "csv_dialect"
12
+ require_relative "csv_input"
13
+ require_relative "row_batches"
14
+
15
+ module Calcsheet
16
+ # The reader backends registered in Calcsheet.backends (CsvReader, XlsxReader, OdsReader), which implement the
17
+ # contract of Reading, and what they share: RowBatches, the SAX handling of SaxSheet and OxErrors, and the byte
18
+ # scanners of TagScan and StreamRemainder. Xlsx and Ods hold the parts of each format.
19
+ #
20
+ # @api private
21
+ module Readers
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 in the detected dialect.
24
+ #
25
+ # Rows are numbered by CSV record, counting the header and blank lines, and +line+ is the physical line where
26
+ # the record starts. A seekable source is read again from its start whenever a request needs a record
27
+ # already parsed or another dialect; a non-seekable stream is read directly, forward only, so it can be read
28
+ # once. The options +encoding+, +col_sep+ and +quote_char+ of the read force the dialect; nil detects it.
29
+ #
30
+ # @api private
31
+ class CsvReader
32
+ include RowBatches
33
+
34
+ # The message of a second read of a stream read directly.
35
+ READ_TWICE = "a non-seekable CSV stream cannot be read twice; pass a file or seekable IO"
36
+ # The line break that does not belong to each line ending: CSV keeps it in an unquoted field. A stray one in a
37
+ # CRLF file CSV refuses itself, with the message UNQUOTED_BREAK starts.
38
+ STRAY_BREAKS = { "\n" => "\r", "\r" => "\n" }.freeze
39
+ UNQUOTED_BREAK = "Unquoted fields do not allow new line"
40
+
41
+ # @return [Boolean] false: a stream is read directly (see Source)
42
+ def self.needs_seek?
43
+ false
44
+ end
45
+
46
+ # @param source [Source]
47
+ # @param options [Options] the book options (+max_cell_bytes+); the read options come with each request's Cast
48
+ def initialize(source, options)
49
+ @source = source
50
+ @max_cell_bytes = options.max_cell_bytes
51
+ @name = sheet_name(source.path)
52
+ # The detection sample cannot be given back to a stream: it is replayed before the rest of it, once (#input
53
+ # consumes it; a stream is read once, see #restart).
54
+ @sample = source.io.read(CsvDialect::SAMPLE_BYTES).to_s.b unless source.seekable?
55
+ # The dialect options of the last restart, and the dialect they gave.
56
+ @forced = nil
57
+ @dialect = nil
58
+ # The parser and the Enumerator over its records (see #shift); nil when the next request starts again.
59
+ @csv = nil
60
+ @rows = nil
61
+ # Whether a stream has been read, which it can be only once.
62
+ @started = false
63
+ # The records parsed, and the physical line where the next one starts.
64
+ @records = 0
65
+ @line = 1
66
+ end
67
+
68
+ # The file name without its directories and extension; an upload's name may hold Windows separators.
69
+ # @return [String] "csv" without a name
70
+ def sheet_name(path)
71
+ name = path && File.basename(path.split(/[\\\/]/).last.to_s, ".*")
72
+ name.nil? || name.empty? ? "csv" : name
73
+ end
74
+
75
+ # @return [Array<SheetInfo>]
76
+ def sheets
77
+ [ SheetInfo.new(name: @name, index: 0) ]
78
+ end
79
+
80
+ # Drops the parser and the sample; the book closes the source.
81
+ def close
82
+ @csv = nil
83
+ @rows = nil
84
+ @sample = nil
85
+ end
86
+
87
+ private
88
+
89
+ # Yields the non-blank rows numbered +start+ or later (see RowBatches; a CSV has one sheet). The counters are
90
+ # updated before each yield, so that a caller breaking out leaves the parser on the next unread record.
91
+ def each_row(_sheet_index, cast, types, start)
92
+ forced = forced_dialect(cast.options)
93
+ # The parser goes on from where the last request stopped only when that is before +start+ and the dialect is
94
+ # the same: a request for a record already parsed (the header after a sample, a second read), another
95
+ # forced dialect or a parser that failed starts again from the first record.
96
+ restart(forced) if @csv.nil? || start <= @records || forced != @forced
97
+ strip = cast.options.strip_formula_prefix
98
+ while (fields = shift)
99
+ number = @records += 1
100
+ line = @line
101
+ raw = @csv.line
102
+ @line += line_breaks(raw)
103
+ @stray = STRAY_BREAKS.fetch(@row_sep = @csv.row_sep, false) if @stray.nil?
104
+ mixed_line_endings(number, line, @row_sep) if @stray && raw.include?(@stray) && stray_break?(raw)
105
+ next if number < start
106
+
107
+ last = fields.size - 1
108
+ last -= 1 while last >= 0 && blank_field?(fields[last], number, last)
109
+ next if last.negative?
110
+
111
+ fields = fields.first(last + 1) if last + 1 < fields.size
112
+ yield raw_row(number, line, fields, cast, types, strip)
113
+ end
114
+ end
115
+
116
+ # Parses from the first record again: a seekable source from its start, a stream from its replayed sample, which
117
+ # is possible once. The dialect is detected again only when the forced options changed.
118
+ # @raise [ArgumentError] READ_TWICE for a stream already read
119
+ def restart(forced)
120
+ raise ArgumentError, READ_TWICE if @started && !@source.seekable?
121
+
122
+ unless @dialect && forced == @forced
123
+ io = @source.seekable? ? @source.io : StringIO.new(@sample)
124
+ @dialect = CsvDialect.detect(io, seekable: @source.seekable?, **forced)
125
+ @forced = forced
126
+ end
127
+ @started = true
128
+ @csv = CSV.new(input, col_sep: @dialect.col_sep, quote_char: @dialect.quote_char, row_sep: @dialect.row_sep)
129
+ @stray = nil
130
+ @rows = nil
131
+ @records = 0
132
+ @line = 1
133
+ end
134
+
135
+ # The text the parser reads: the source after the byte order mark, or the sample (consumed here) and the rest of
136
+ # the stream.
137
+ def input
138
+ bom = @dialect.bom_size
139
+ return CsvInput.new(@source.io.tap { _1.seek(bom, IO::SEEK_SET) }, @dialect.encoding) if @source.seekable?
140
+
141
+ prefix = @sample.byteslice(bom, @sample.bytesize - bom)
142
+ @sample = nil
143
+ CsvInput.new(@source.io, @dialect.encoding, prefix: prefix)
144
+ end
145
+
146
+ # The next record, nil at the end. Records are pulled from an Enumerator over CSV#each, not CSV#shift: in csv
147
+ # 3.3.x each shift abandons a parse and leaks one scanner position, so memory grows with the records read. The
148
+ # parser is created, and reads its first bytes, by the first call, inside the error handling below.
149
+ def shift
150
+ (@rows ||= @csv.each).next
151
+ rescue StopIteration
152
+ nil
153
+ rescue CSV::MalformedCSVError, EncodingError => error
154
+ row_sep = @csv.row_sep
155
+ @csv = @rows = nil
156
+ record = @records + 1
157
+ mixed_line_endings(record, @line, row_sep) if error.message.start_with?(UNQUOTED_BREAK)
158
+ raise MalformedFileError.new("CSV record #{record} (line #{@line}): #{error.message}", sheet: @name, row: record)
159
+ rescue StandardError
160
+ # A parser that failed (a size limit, an IO error) cannot continue: the next request starts again.
161
+ @csv = @rows = nil
162
+ raise
163
+ end
164
+
165
+ # Physical lines a record ends: "\r\n", "\n" and "\r" each end one, inside quoted fields too.
166
+ def line_breaks(raw)
167
+ breaks = raw.count("\n")
168
+ position = 0
169
+ while (cr = raw.byteindex("\r", position))
170
+ breaks += 1 unless raw.getbyte(cr + 1) == 0x0A
171
+ position = cr + 1
172
+ end
173
+ breaks
174
+ end
175
+
176
+ # Whether the stray line break of a record is outside its quoted fields: the parts between quote characters
177
+ # alternate outside and inside, a doubled quote giving an empty inside part.
178
+ def stray_break?(raw)
179
+ raw.split(@dialect.quote_char, -1).each_slice(2).any? { |outside, _inside| outside.include?(@stray) }
180
+ end
181
+
182
+ # Records end with the first line ending of the file; another one outside quoted fields would leave a CR in
183
+ # a value or merge two records.
184
+ def mixed_line_endings(record, line, row_sep)
185
+ @csv = @rows = nil
186
+ raise MalformedFileError.new("CSV record #{record} (line #{line}): mixed line endings: the file's lines end " \
187
+ "with #{row_sep.inspect}, this record has another line ending outside " \
188
+ "quoted fields", sheet: @name, row: record)
189
+ end
190
+
191
+ # The dialect options of a read (checked by OptionValidation, except whether the encoding can be read): nil
192
+ # detects.
193
+ def forced_dialect(options)
194
+ { encoding: options.encoding && readable_encoding(options.encoding), col_sep: options.col_sep,
195
+ quote_char: options.quote_char }
196
+ end
197
+
198
+ # The encoding named by +value+, when it can be transcoded to UTF-8.
199
+ def readable_encoding(value)
200
+ encoding = Encoding.find(value)
201
+ Encoding::Converter.new(encoding, Encoding::UTF_8) unless encoding == Encoding::UTF_8
202
+ encoding
203
+ rescue ArgumentError, Encoding::ConverterNotFoundError => error
204
+ raise ArgumentError, "encoding: cannot read #{value.inspect} (#{error.message})"
205
+ end
206
+
207
+ def raw_row(number, line, fields, cast, types, strip)
208
+ errors = nil
209
+ # Array.new with a block rather than each_with_index.map, which allocates an Enumerator per row.
210
+ values = Array.new(fields.size) do |index|
211
+ field = fields[index]
212
+ next if field.nil? || field.empty?
213
+
214
+ check_size(field, number, index) if @max_cell_bytes && field.bytesize > @max_cell_bytes
215
+ field = FormulaProtection.unprotect(field) if strip
216
+ cast.text(field, types&.[](index))
217
+ rescue Cast::Failure => failure
218
+ (errors ||= []) << [ index, field, failure.reason ]
219
+ nil
220
+ end
221
+ RawRow.new(number: number, line: line, values: values, errors: errors)
222
+ end
223
+
224
+ # Every field is measured before a regexp sees it: here those from the end to the last non-blank one, the
225
+ # others in raw_row.
226
+ def blank_field?(field, number, index)
227
+ return true if field.nil?
228
+
229
+ check_size(field, number, index) if @max_cell_bytes && field.bytesize > @max_cell_bytes
230
+ !Reading::NON_BLANK.match?(field)
231
+ end
232
+
233
+ def check_size(field, number, index)
234
+ raise MalformedFileError.at("the field is longer than max_cell_bytes (#{@max_cell_bytes} bytes)",
235
+ sheet: @name, row: number, column_index: index)
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ module Readers
5
+ module Ods
6
+ # What the styles of an ODS document say about how a value reads, gathered by Scanner from the data
7
+ # styles and the cell styles of styles.xml and of the automatic styles of content.xml: a date style that shows a
8
+ # time or none, and a plain zero-padding number style (+00000+: one number:number without decimals or grouping,
9
+ # nothing else). The Scanner feeds the elements in document order; #to_h resolves each cell style through its
10
+ # parents.
11
+ #
12
+ # @api private
13
+ class CellFormats
14
+ # Parents followed from a cell style to the one that names its data style; a longer chain is not followed.
15
+ MAX_PARENTS = 8
16
+
17
+ # @return [Symbol, nil] :date or :number while inside a data style that matters, else nil
18
+ attr_reader :current
19
+ # @return [Integer, nil] the digits of the zero-padding number of the current number style
20
+ attr_reader :digits
21
+
22
+ # +@cells+ holds +[data style, parent]+ by cell style name, +@formats+ the +[kind, pad]+ by data style name.
23
+ def initialize
24
+ @cells = {}
25
+ @formats = {}
26
+ @current = nil
27
+ end
28
+
29
+ # @param name [String] a cell style
30
+ # @param data [String, nil] its data style
31
+ # @param parent [String, nil] its parent style
32
+ def cell_style(name, data, parent)
33
+ @cells[name] = [ data, parent ].freeze
34
+ end
35
+
36
+ # Starts a data style of +kind+ (:date or :number) named +name+.
37
+ def start(kind, name)
38
+ @current = kind
39
+ @name = name
40
+ @time = false
41
+ @digits = nil
42
+ @plain = true
43
+ end
44
+
45
+ # A time field of the current date style.
46
+ def time!
47
+ @time = true
48
+ end
49
+
50
+ # Whether the current number style may still be a plain zero-padding format.
51
+ def plain?
52
+ @plain
53
+ end
54
+
55
+ # The first number:number of the current number style: the digits of a zero-padding format, or nil.
56
+ def number!(digits)
57
+ @digits = digits
58
+ @plain = false if digits.nil?
59
+ end
60
+
61
+ # Another element in the current number style: it is not a plain zero-padding format.
62
+ def not_plain!
63
+ @plain = false
64
+ end
65
+
66
+ # Keeps the format of the data style that ends, when it has one (a date style; a number style that stayed a
67
+ # plain zero padding), and leaves it.
68
+ def finish
69
+ if @name && @current == :date
70
+ @formats[@name] = [ @time ? :datetime : :date, nil ].freeze
71
+ elsif @name && @current == :number && @plain && @digits
72
+ @formats[@name] = [ :general, @digits ].freeze
73
+ end
74
+ @current = nil
75
+ end
76
+
77
+ # @param other [CellFormats] styles that win over these (content.xml over styles.xml)
78
+ # @return [CellFormats]
79
+ def merge(other)
80
+ merged = CellFormats.new
81
+ merged.absorb(@cells.merge(other.cells), @formats.merge(other.formats))
82
+ merged
83
+ end
84
+
85
+ # @return [Hash{String => Array(Symbol, Integer | nil)}] +[kind, pad]+ (see Scanner#cell_formats) of each cell
86
+ # style whose data style, its own or its nearest parent's, has one
87
+ def to_h
88
+ @cells.each_key.filter_map { |name| (format = resolve(name)) && [ name, format ] }.to_h.freeze
89
+ end
90
+
91
+ protected
92
+
93
+ attr_reader :cells, :formats
94
+
95
+ def absorb(cells, formats)
96
+ @cells = cells
97
+ @formats = formats
98
+ end
99
+
100
+ private
101
+
102
+ def resolve(name)
103
+ MAX_PARENTS.times do
104
+ data, parent = @cells[name]
105
+ return @formats[data] if data
106
+ return if parent.nil?
107
+
108
+ name = parent
109
+ end
110
+ nil
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end