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,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ox"
4
+ require_relative "../../sheet_info"
5
+ require_relative "../../errors"
6
+ require_relative "../../untrusted"
7
+ require_relative "../ox_errors"
8
+ require_relative "../references"
9
+ require_relative "local_name"
10
+ require_relative "number_format"
11
+ require_relative "styles"
12
+
13
+ module Calcsheet
14
+ module Readers
15
+ # The parts of an XLSX workbook (Office Open XML SpreadsheetML) that XlsxReader reads: WorkbookMeta (sheets, date
16
+ # system, Styles), SharedStrings, the SheetParser of a sheet and the RowSkipper of +start_at:+.
17
+ module Xlsx
18
+ # What the small parts of an XLSX workbook say: the sheets in workbook order with the entry that holds each and
19
+ # the date system, read in full with Ox from parts of at most MAX_PART_BYTES, one at a time (the workbook is
20
+ # reduced to its sheets before its relationships are parsed), and how each cell style reads a number,
21
+ # streamed from the styles by Styles. Elements and attributes are matched by local name, because generators use
22
+ # other namespace prefixes (+x:sheet+, +rel:id+). The references of the attributes read are decoded by the gem
23
+ # (see References), as in cells: Ox would truncate a sheet name at +�+.
24
+ #
25
+ # @api private
26
+ class WorkbookMeta
27
+ WORKBOOK = "xl/workbook.xml"
28
+ RELATIONSHIPS = "xl/_rels/workbook.xml.rels"
29
+ STYLES = "xl/styles.xml"
30
+ # The +[kind, pad]+ of a cell without a known style.
31
+ GENERAL = [ :general, nil ].freeze
32
+ # The parts read whole (the workbook and its relationships) inflate to at most this. Real ones are
33
+ # a few KiB; the DOM Ox builds of a part of tiny elements costs tens of times its size (ADR 008).
34
+ MAX_PART_BYTES = 1_048_576
35
+ # The +state+ values of a hidden sheet.
36
+ HIDDEN_STATES = %w[hidden veryHidden].freeze
37
+
38
+ # @param zip [ZipContainer] the opened workbook
39
+ # @param max_sheets [Integer, nil] the most sheets the workbook may list; nil for no limit
40
+ # @return [WorkbookMeta]
41
+ # @raise [TooManySheetsError] when the workbook lists more sheets than +max_sheets+
42
+ # @raise [MalformedFileError] when workbook.xml or its relationships are missing or unparseable, or a sheet's
43
+ # relationship is unknown (the parser error is the +cause+)
44
+ def self.load(zip, max_sheets: nil)
45
+ # Each DOM is reduced to what the workbook needs before the next part is parsed, so that at most one is alive.
46
+ date1904, sheets = references_of(WORKBOOK) { read_workbook(parse(zip, WORKBOOK, "workbook"), max_sheets) }
47
+ targets = references_of(RELATIONSHIPS) { relationship_targets(parse(zip, RELATIONSHIPS, "Relationships")) }
48
+ new(date1904, sheets, targets, zip.entry?(STYLES) ? load_styles(zip) : nil)
49
+ end
50
+
51
+ # @api private
52
+ # @return [Array(Boolean, Array<Array(String, String | nil, String | nil)>)] whether dates count from 1904, and
53
+ # +[name, relationship id, state]+ of each sheet in workbook order
54
+ # @raise [TooManySheetsError] when the workbook lists more sheets than +max_sheets+
55
+ def self.read_workbook(workbook, max_sheets)
56
+ properties = children(workbook, "workbookPr").first
57
+ date1904 = %w[1 true].include?(properties && attribute(properties, :date1904))
58
+ list = children(workbook, "sheets").flat_map { children(_1, "sheet") }
59
+ if max_sheets && list.size > max_sheets
60
+ raise TooManySheetsError.new(limit: :max_sheets, max: max_sheets, actual: list.size)
61
+ end
62
+
63
+ [ date1904, list.map { |sheet| [ attribute(sheet, :name).to_s, attribute(sheet, :id), attribute(sheet, :state) ] } ]
64
+ end
65
+
66
+ # @api private
67
+ # @yieldreturn [Object] what the block reads of the part +name+
68
+ # @raise [MalformedFileError] naming the part, for an attribute that holds an invalid reference
69
+ def self.references_of(name)
70
+ yield
71
+ rescue References::Invalid => error
72
+ raise MalformedFileError, "#{name}: #{error.message}"
73
+ end
74
+
75
+ # @api private
76
+ # @return [Hash{String => String}] the target of each relationship id
77
+ def self.relationship_targets(relationships)
78
+ children(relationships, "Relationship").to_h { |node| [ attribute(node, :Id), attribute(node, :Target) ] }
79
+ end
80
+
81
+ # @api private
82
+ # @return [Array<Array(Symbol, Integer | nil)>] see Styles
83
+ def self.load_styles(zip)
84
+ stream = zip.stream(STYLES)
85
+ begin
86
+ Styles.load(stream)
87
+ ensure
88
+ stream.close
89
+ end
90
+ end
91
+
92
+ # @api private
93
+ # @return [Ox::Element] the root element of a part read whole, whose local name must be +root_name+
94
+ # @raise [MalformedFileError] for a missing, damaged or longer part, or another root
95
+ def self.parse(zip, name, root_name)
96
+ raise MalformedFileError, "the workbook has no #{name}" unless zip.entry?(name)
97
+
98
+ # convert_special: false, so that the references reach #attribute as written (see References).
99
+ document = Ox.load(zip.read(name, max_bytes: MAX_PART_BYTES), mode: :generic, convert_special: false)
100
+ # Without an XML declaration Ox returns the root element itself, with one a Document around it.
101
+ root = document.is_a?(Ox::Document) ? elements(document).first : document
102
+ unless root.is_a?(Ox::Element) && LocalName.of(root.name) == root_name
103
+ raise MalformedFileError, "#{name} is not a #{root_name} part"
104
+ end
105
+
106
+ root
107
+ rescue Ox::Error, EncodingError => error
108
+ # EncodingError: an element or attribute name that is not valid UTF-8.
109
+ OxErrors.raise_translated(error) { MalformedFileError.new("#{name} cannot be parsed: #{_1}") }
110
+ end
111
+
112
+ # @api private
113
+ # @return [Array<Ox::Element>] the child elements of +node+
114
+ def self.elements(node)
115
+ node.nodes.grep(Ox::Element)
116
+ end
117
+
118
+ # @api private
119
+ # @return [Array<Ox::Element>] the child elements of +node+ of local name +name+
120
+ def self.children(node, name)
121
+ elements(node).select { LocalName.of(_1.name) == name }
122
+ end
123
+
124
+ # @api private
125
+ # @return [String, nil] the value of the attribute of local name +name+, its references decoded
126
+ # @raise [MalformedFileError] for a value that is not valid UTF-8
127
+ # @raise [References::Invalid] for a value that holds an invalid reference (see #references_of)
128
+ def self.attribute(node, name)
129
+ value = node.attributes.find { |key, _| LocalName.of(key) == name.to_s }&.last
130
+ return value if value.nil?
131
+
132
+ element = Untrusted.show(LocalName.of(node.name))
133
+ raise MalformedFileError, "the #{name} attribute of a #{element} element is not valid UTF-8" unless value.valid_encoding?
134
+ return value unless value.include?("&")
135
+
136
+ begin
137
+ References.decode(value)
138
+ rescue References::Invalid => error
139
+ raise References::Invalid, "the #{name} attribute of a #{element} element holds #{error.message}"
140
+ end
141
+ end
142
+
143
+ # @return [Array<SheetInfo>] the sheets in workbook order, hidden ones included
144
+ attr_reader :sheets
145
+
146
+ # @api private
147
+ # @return [Array<String>] the zip entry of each sheet, parallel to +sheets+ (a test seam: the reader uses
148
+ # #sheet_path)
149
+ attr_reader :sheet_paths
150
+
151
+ # @api private
152
+ # @return [Array<Array(Symbol, Integer | nil)>] +[kind, pad]+ per cell style index (the +s+ of a cell): kind is
153
+ # +:general+, +:date+, +:datetime+ or +:time+ (see NumberFormat); empty when the workbook has no styles (a test
154
+ # seam: the reader uses #style_kind and #style_count)
155
+ attr_reader :style_kinds
156
+
157
+ # @param date1904 [Boolean] whether dates count from 1904-01-01
158
+ # @param sheets [Array<Array(String, String | nil, String | nil)>] see WorkbookMeta.read_workbook
159
+ # @param targets [Hash{String => String}] see WorkbookMeta.relationship_targets
160
+ # @param styles [Array<Array(Symbol, Integer | nil)>, nil] the style kinds Styles read; nil without styles
161
+ def initialize(date1904, sheets, targets, styles)
162
+ @date1904 = date1904
163
+ @sheets, @sheet_paths = read_sheets(sheets, targets)
164
+ @style_kinds = styles || []
165
+ [ @sheets, @sheet_paths, @style_kinds ].each(&:freeze)
166
+ end
167
+
168
+ # @return [Boolean] whether dates count from 1904-01-01
169
+ def date1904?
170
+ @date1904
171
+ end
172
+
173
+ # @param index [Integer] the 0-based position of the sheet in +sheets+
174
+ # @return [String] the zip entry name of its XML
175
+ def sheet_path(index)
176
+ @sheet_paths.fetch(index)
177
+ end
178
+
179
+ # @return [Integer] the number of cell styles (cellXfs), 0 without styles
180
+ def style_count
181
+ @style_kinds.size
182
+ end
183
+
184
+ # @param index [Integer, nil] a cell style index; nil or beyond the styles is general
185
+ # @return [Array(Symbol, Integer | nil)] +[kind, pad]+
186
+ def style_kind(index)
187
+ (index && @style_kinds[index]) || GENERAL
188
+ end
189
+
190
+ private
191
+
192
+ def read_sheets(sheets, targets)
193
+ infos = []
194
+ paths = []
195
+ sheets.each_with_index do |(name, id, state), index|
196
+ infos << SheetInfo.new(name: name, index: index, hidden: HIDDEN_STATES.include?(state))
197
+ paths << sheet_path_of(id, name, targets)
198
+ end
199
+ [ infos, paths ]
200
+ end
201
+
202
+ def sheet_path_of(id, name, targets)
203
+ raise MalformedFileError, "sheet '#{Untrusted.cut(name)}' has no relationship id" if id.nil?
204
+
205
+ target = targets[id]
206
+ if target.nil?
207
+ raise MalformedFileError, "sheet '#{Untrusted.cut(name)}' refers to the unknown relationship '#{Untrusted.cut(id)}'"
208
+ end
209
+
210
+ resolve(target)
211
+ end
212
+
213
+ # A target is relative to xl/, or absolute from the root of the archive.
214
+ def resolve(target)
215
+ segments = target.start_with?("/") ? [] : [ "xl" ]
216
+ target.split("/").each do |segment|
217
+ case segment
218
+ when "", "." then next
219
+ when ".." then segments.pop
220
+ else segments << segment
221
+ end
222
+ end
223
+ segments.join("/")
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../entry_name"
4
+ require_relative "../errors"
5
+ require_relative "../untrusted"
6
+ require_relative "../zip_container"
7
+ require_relative "xlsx/workbook_meta"
8
+ require_relative "xlsx/shared_strings"
9
+ require_relative "xlsx/row_skipper"
10
+ require_relative "xlsx/sheet_parser"
11
+ require_relative "row_batches"
12
+
13
+ module Calcsheet
14
+ module Readers
15
+ # The XLSX reader backend (see Reading for the contract): the sheets of +xl/workbook.xml+, each streamed from its
16
+ # zip entry by SheetParser. The shared strings are loaded once per book, at the first cell that needs
17
+ # them. A read that starts after the first row skips the rows before it with RowSkipper, without parsing them
18
+ # (+start_at:+); rows without +r+ cannot be found that way and are parsed and discarded instead.
19
+ #
20
+ # @api private
21
+ class XlsxReader
22
+ include RowBatches
23
+
24
+ # The shared strings part, where Excel and LibreOffice write it.
25
+ SHARED_STRINGS = "xl/sharedStrings.xml"
26
+ # What a stream resumed by RowSkipper is parsed inside: the sheetData element the skipped bytes opened, in a
27
+ # document declared as UTF-8 like the sheet. A synthetic wrapper: it has no worksheet root and no namespace
28
+ # declarations, which Ox does not check (SheetParser strips the prefixes, whatever they are), and the parser
29
+ # stops at the end tag of sheetData before it would miss the worksheet's.
30
+ RESUMED_PREFIX = '<?xml version="1.0" encoding="UTF-8"?><sheetData>'.b.freeze
31
+
32
+ # @return [Boolean] true: the zip central directory needs random access
33
+ def self.needs_seek?
34
+ true
35
+ end
36
+
37
+ # @param source [Source] a seekable source
38
+ # @param options [Options] the book options; the zip limits come from them
39
+ # @raise [MalformedFileError] when the archive or the workbook parts cannot be read
40
+ # @raise [ZipBombError] when a zip limit is exceeded
41
+ # @raise [TooManySheetsError] when the workbook has more sheets than +max_sheets+
42
+ def initialize(source, options)
43
+ @zip = ZipContainer.open(source, options)
44
+ @meta = Xlsx::WorkbookMeta.load(@zip, max_sheets: options.max_sheets)
45
+ @max_cell_bytes = options.max_cell_bytes
46
+ @shared_strings = nil
47
+ end
48
+
49
+ # @return [Array<SheetInfo>]
50
+ def sheets
51
+ @meta.sheets
52
+ end
53
+
54
+ # @return [Boolean] whether the workbook counts dates from 1904
55
+ def date1904?
56
+ @meta.date1904?
57
+ end
58
+
59
+ # Drops the shared strings; the book closes the source.
60
+ def close
61
+ @shared_strings = nil
62
+ end
63
+
64
+ private
65
+
66
+ def each_row(index, cast, types, start_row, &block)
67
+ sheet = @meta.sheets.fetch(index)
68
+ path = @meta.sheet_path(index)
69
+ unless @zip.entry?(path)
70
+ raise MalformedFileError.new("sheet '#{Untrusted.cut(sheet.name)}' has no part #{EntryName.show(path)} in the archive", sheet: sheet.name)
71
+ end
72
+
73
+ # RowSkipper passes over the rows before +start_row+ without the max_cell_bytes bound; the
74
+ # StreamRemainder applies it to what the parser reads.
75
+ stream = @zip.stream(path, bound_tokens: start_row <= 1)
76
+ begin
77
+ io = start_row > 1 ? resume(stream, start_row, sheet.name, path) : stream
78
+ if io == :unnumbered
79
+ stream.close
80
+ io = stream = @zip.stream(path)
81
+ end
82
+ return if io.nil?
83
+
84
+ Xlsx::SheetParser.parse(io, sheet: sheet.name, cast: cast, types: types, styles: @meta,
85
+ strings: -> { shared_strings }, start_row: start_row, max_cell_bytes: @max_cell_bytes,
86
+ &block)
87
+ ensure
88
+ stream.close
89
+ end
90
+ end
91
+
92
+ # @return [IO, :unnumbered, nil] the stream from the row inside a synthetic sheetData; :unnumbered when the
93
+ # rows have no r; nil when the sheet has no such row
94
+ # @raise [MalformedFileError] for an error of the stream that is not a Calcsheet::Error (kept as +cause+), as
95
+ # SheetParser does
96
+ def resume(stream, start_row, name, path)
97
+ skipper = Xlsx::RowSkipper.new(stream, sheet: name)
98
+ rest = skipper.skip_to(start_row, prefix: RESUMED_PREFIX, bound: @zip.token_bound(path))
99
+ skipper.unnumbered? ? :unnumbered : rest
100
+ rescue IOError, SystemCallError => error
101
+ raise MalformedFileError.at("the sheet cannot be read: #{error.message}", sheet: name), cause: error
102
+ end
103
+
104
+ def shared_strings
105
+ @shared_strings ||= load_shared_strings
106
+ end
107
+
108
+ def load_shared_strings
109
+ raise MalformedFileError, "the workbook has no #{SHARED_STRINGS} but its cells refer to it" unless @zip.entry?(SHARED_STRINGS)
110
+
111
+ # Kept in memory: not compression_ratio_floor, whose 512 MiB of tiny strings would take gigabytes, but the
112
+ # small ZipContainer::MEMORY_RATIO_FLOOR.
113
+ stream = @zip.stream(SHARED_STRINGS, ratio_floor: false)
114
+ begin
115
+ Xlsx::SharedStrings.load(stream, max_cell_bytes: @max_cell_bytes)
116
+ ensure
117
+ stream.close
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cast"
4
+ require_relative "errors"
5
+ require_relative "header_keys"
6
+ require_relative "merge_check"
7
+ require_relative "raw_row"
8
+ require_relative "raw_types"
9
+ require_relative "row"
10
+ require_relative "schema"
11
+ require_relative "sheet_selection"
12
+
13
+ module Calcsheet
14
+ # One read of a book: options, sheet selection, header, schema, error policy and limits applied to the raw rows
15
+ # of a backend.
16
+ #
17
+ # == Backend contract
18
+ #
19
+ # Every reader backend is a class registered in Calcsheet.backends and implements:
20
+ #
21
+ # - +Klass.needs_seek?+ -> Boolean: whether the backend needs a seekable source (a non-seekable stream is then
22
+ # copied to a Tempfile by Source).
23
+ # - +Klass.new(source, options)+ with a Source (its +io+, binary and seekable when +needs_seek?+, +path+,
24
+ # +seekable?+ and +size+) and the Options of the book. The book closes the source, not the backend.
25
+ # - +#sheets+ -> Array<SheetInfo>, in workbook order, hidden sheets included; +SheetInfo#index+ is the position.
26
+ # - +#first_rows(sheet_index, count, cast:, types: nil)+ -> Array<RawRow>: the first +count+ non-blank rows.
27
+ # - +#each_raw_batch(sheet_index, size:, cast:, types:, start_row: 1) { |Array<RawRow>| }+: the non-blank rows
28
+ # with +number >= start_row+, in order, in batches of at most +size+ rows, read one batch at a time so that
29
+ # breaking out of the block stops reading.
30
+ # - +#close+: releases what the backend opened (never the source, which the book closes).
31
+ # - optionally +#date1904?+: the workbook counts date serials from 1904.
32
+ #
33
+ # Rows are numbered as the user sees them; +values+ are in file order with trailing blank cells trimmed.
34
+ # Each cell is cast with +cast+ (a Cast) and the declared type +types[column_index]+, where +types+ is nil or
35
+ # an object answering +[](column_index)+ with a type of TYPES or nil (an Array, or a RawTypes, which also gives
36
+ # a type to the columns beyond its entries); nil means the native value. A backend rescues Cast::Failure per
37
+ # cell, puts nil in +values+ and appends +[column_index, raw_value, reason]+ to +errors+. The Options of the read
38
+ # (which may override those of the book, but not OPEN_KEYS) are +cast.options+.
39
+ #
40
+ # Reading itself is the facade's: Book builds one per read.
41
+ class Reading
42
+ # The read options; every other keyword is an Options override.
43
+ KEYS = %i[sheet header columns types on_error start_at skip_blank include_hidden required_headers].freeze
44
+ # The Options the book applies when the file is opened (the source, the zip archive, the backend): a read cannot
45
+ # override them.
46
+ OPEN_KEYS = %i[max_file_size max_uncompressed_size max_compression_ratio compression_ratio_floor max_zip_entries
47
+ max_sheets max_cell_bytes].freeze
48
+ # The errors of a row without any, shared.
49
+ NO_ERRORS = [].freeze
50
+ # A character that is not whitespace: a text is blank unless it has one. A search, not an anchored
51
+ # /\A[[:space:]]*\z/, whose backtracking costs memory in proportion to the length of the text.
52
+ NON_BLANK = /[^[:space:]]/
53
+
54
+ # @api private
55
+ # @param read [Hash] read options and Options overrides mixed
56
+ # @return [Array(Hash, Hash)] the read options and the overrides
57
+ def self.split(read)
58
+ read.partition { |key, _value| KEYS.include?(key) }.map(&:to_h)
59
+ end
60
+
61
+ # @api private
62
+ # @return [Options] the options of this read
63
+ attr_reader :options
64
+ # @api private
65
+ # @return [#call, nil] called after each batch the backend hands over; Book raises IOError there once closed
66
+ attr_writer :open_check
67
+
68
+ # @api private
69
+ # @param sheets [Array<SheetInfo>] every sheet of the book
70
+ # @param options [Options] the options of the book
71
+ # @param overrides [Hash] Options overrides of this read
72
+ # @raise [ArgumentError] on an invalid read option or an override of an OPEN_KEYS option
73
+ # @raise [SheetNotFoundError] for a missing sheet
74
+ def initialize(backend, sheets, options, overrides = {}, sheet: nil, header: true, columns: nil, types: :native,
75
+ on_error: :collect, start_at: nil, skip_blank: true, include_hidden: false, required_headers: nil)
76
+ opened = overrides.keys.find { OPEN_KEYS.include?(_1) }
77
+ raise ArgumentError, "#{opened}: applies when the file is opened: pass it to Calcsheet.open" if opened
78
+
79
+ @backend = backend
80
+ @options = overrides.empty? ? options : Calcsheet.config.options(**options.to_h, **overrides)
81
+ @header = flag(:header, header)
82
+ @skip_blank = flag(:skip_blank, skip_blank)
83
+ @string_types = choice(:types, types, %i[native string]) == :string
84
+ @raise = choice(:on_error, on_error, %i[collect raise]) == :raise
85
+ @selected = SheetSelection.resolve(sheets, sheet, include_hidden: flag(:include_hidden, include_hidden))
86
+ raise ArgumentError, "reading #{@selected.size} sheets as one requires header: true" if @selected.size > 1 && !@header
87
+ raise ArgumentError, "required_headers: requires header: true" if required_headers && !@header
88
+
89
+ @required_headers = required_headers
90
+ @schema = columns && Schema.new(columns, header: @header)
91
+ @start_sheet, @start_row = start_point(sheets, start_at)
92
+ @cast = Cast.new(@options, date1904: backend.respond_to?(:date1904?) && backend.date1904? == true)
93
+ end
94
+
95
+ # @api private
96
+ # @return [MergeCheck] the comparison of the headers of the selected sheets
97
+ def merge_check
98
+ MergeCheck.of(@selected.map { |sheet| [ sheet.name, header_row(sheet)&.values ] })
99
+ end
100
+
101
+ # @api private
102
+ # @param count [Integer] data rows per sheet
103
+ # @return [Array<Sample>] the header and first rows of each selected sheet, native values
104
+ def sample(count)
105
+ @selected.map do |sheet|
106
+ header, *rows = @backend.first_rows(sheet.index, count + 1, cast: @cast, types: nil)
107
+ prepare(header)
108
+ Sample.new(sheet: sheet, header: header && convert(sheet, header),
109
+ rows: rows.map { |raw| convert(sheet, raw) }.freeze)
110
+ end
111
+ end
112
+
113
+ # @api private
114
+ # Yields the rows of the selected sheets. The headers are read and checked before the first row.
115
+ # @param batch_size [Integer] rows the backend reads at a time
116
+ # @yieldparam row [Row]
117
+ # @raise [HeaderMismatchError] the selected sheets have different headers
118
+ # @raise [ColumnNotFoundError] a header mapping or required header matches no header cell
119
+ # @raise [AmbiguousColumnError] a header mapping matches several header cells
120
+ # @raise [TooManyRowsError] more rows than +max_rows+
121
+ # @raise [CellError] the first cell error, with +on_error: :raise+
122
+ def each(batch_size)
123
+ headers = @selected.map { |sheet| [ sheet, (header_row(sheet) if @header) ] }
124
+ check_merge(headers) if headers.size > 1
125
+ reference = headers.find { |_sheet, header| header }&.last
126
+ Schema.required_headers!(@required_headers, reference&.values) if @required_headers
127
+ # Every sheet is empty in header mode: no row to yield, so no schema to resolve against a missing header.
128
+ return if @header && reference.nil?
129
+
130
+ prepare(reference)
131
+ max = @options.max_rows
132
+ count = 0
133
+ headers.drop(@selected.index(@start_sheet)).each do |sheet, header|
134
+ next if @header && header.nil?
135
+
136
+ each_raw(sheet, first_row(sheet, header), batch_size) do |raw|
137
+ count += 1
138
+ raise TooManyRowsError.new(limit: :max_rows, max: max, actual: count) if max && count > max
139
+
140
+ yield convert(sheet, raw)
141
+ end
142
+ end
143
+ end
144
+
145
+ private
146
+
147
+ def flag(name, value)
148
+ return value if value == true || value == false
149
+
150
+ raise ArgumentError, "#{name}: must be true or false, got #{value.inspect}"
151
+ end
152
+
153
+ def choice(name, value, allowed)
154
+ return value if allowed.include?(value)
155
+
156
+ raise ArgumentError, "#{name}: must be one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
157
+ end
158
+
159
+ # @return [Array(SheetInfo, Integer)] the sheet to start from and the first row number to read in it
160
+ def start_point(sheets, start_at)
161
+ case start_at
162
+ when nil then [ @selected.first, 1 ]
163
+ when Integer
164
+ raise ArgumentError, "start_at: a row number needs a single sheet; use { sheet:, row: }" if @selected.size > 1
165
+
166
+ [ @selected.first, start_row(start_at) ]
167
+ when Hash
168
+ unless start_at.keys.sort == %i[row sheet]
169
+ raise ArgumentError, "start_at: expected { sheet:, row: }, got #{start_at.inspect}"
170
+ end
171
+
172
+ sheet = SheetSelection.find(sheets, start_at[:sheet])
173
+ raise ArgumentError, "start_at: sheet #{start_at[:sheet].inspect} is not read" unless @selected.include?(sheet)
174
+
175
+ [ sheet, start_row(start_at[:row]) ]
176
+ else raise ArgumentError, "start_at: expected a row number or { sheet:, row: }, got #{start_at.inspect}"
177
+ end
178
+ end
179
+
180
+ def start_row(row)
181
+ return row if row.is_a?(Integer) && row >= 1
182
+
183
+ raise ArgumentError, "start_at: the row must be a positive Integer, got #{row.inspect}"
184
+ end
185
+
186
+ def first_row(sheet, header)
187
+ first = header ? header.number + 1 : 1
188
+ sheet == @start_sheet && @start_row > first ? @start_row : first
189
+ end
190
+
191
+ # The header row is read with native types: declared types apply to data rows only.
192
+ def header_row(sheet)
193
+ @backend.first_rows(sheet.index, 1, cast: @cast, types: nil).first
194
+ end
195
+
196
+ def check_merge(headers)
197
+ check = MergeCheck.of(headers.map { |sheet, header| [ sheet.name, header&.values ] })
198
+ raise HeaderMismatchError.new(mismatches: check.mismatches) unless check.mergeable?
199
+ end
200
+
201
+ # Binds the read to the header (nil without one): keys, schema positions and the types given to the backend.
202
+ def prepare(header)
203
+ values = header&.values
204
+ @resolved = @schema&.resolve(values)
205
+ if @resolved
206
+ @keys = Row::Keys.new(@resolved.keys, letters: false)
207
+ @required = @resolved.required.any?
208
+ else
209
+ @keys = Row::Keys.new(values ? HeaderKeys.build(values) : [], letters: true)
210
+ @width = values ? values.size : 0
211
+ end
212
+ by_index = @resolved ? @resolved.types : []
213
+ @types =
214
+ if @string_types then RawTypes.new(by_index: by_index, default: :string)
215
+ elsif by_index.any? then by_index
216
+ end
217
+ end
218
+
219
+ def each_raw(sheet, first, size)
220
+ expected = first
221
+ @backend.each_raw_batch(sheet.index, size: size, cast: @cast, types: @types, start_row: first) do |batch|
222
+ batch.each do |raw|
223
+ unless @skip_blank
224
+ (expected...raw.number).each { |number| yield blank_row(raw, number) }
225
+ expected = raw.number + 1
226
+ end
227
+ yield raw
228
+ end
229
+ # A book closed while its rows were yielded stops the read before the backend reads on.
230
+ @open_check&.call
231
+ end
232
+ end
233
+
234
+ # A blank row before +following+: a blank record or row is one physical line.
235
+ def blank_row(following, number)
236
+ RawRow.new(number: number, line: following.line && following.line - (following.number - number), values: [])
237
+ end
238
+
239
+ def convert(sheet, raw)
240
+ if @resolved
241
+ values = @resolved.source_indices.map { |index| raw.values[index] }
242
+ errors = schema_errors(sheet, raw, values)
243
+ else
244
+ values = raw.values
245
+ values += Array.new(@width - values.size) if values.size < @width
246
+ @keys.cover(values.size)
247
+ errors = raw.errors ? raw.errors.map { |index, value, reason| cell_error(sheet, raw, index, nil, value, reason) } : NO_ERRORS
248
+ end
249
+ raise errors.first if @raise && !errors.empty?
250
+
251
+ Row.new(sheet: sheet.name, sheet_index: sheet.index, number: raw.number, line: raw.line, values: values,
252
+ errors: errors, keys: @keys)
253
+ end
254
+
255
+ # Errors of the mapped columns in schema order: the cast failure of the cell, else a blank required cell.
256
+ def schema_errors(sheet, raw, values)
257
+ return NO_ERRORS if raw.errors.nil? && !@required
258
+
259
+ failures = raw.errors.to_h { |index, value, reason| [ index, [ value, reason ] ] } if raw.errors
260
+ errors = []
261
+ @resolved.source_indices.each_with_index do |index, position|
262
+ key = @resolved.keys[position]
263
+ failure = failures && failures[index]
264
+ if failure
265
+ errors << cell_error(sheet, raw, index, key, *failure)
266
+ elsif @resolved.required[position] && blank?(values[position])
267
+ errors << cell_error(sheet, raw, index, key, nil, :required)
268
+ end
269
+ end
270
+ errors.empty? ? NO_ERRORS : errors.freeze
271
+ end
272
+
273
+ def blank?(value)
274
+ value.nil? || (value.is_a?(String) && !NON_BLANK.match?(value))
275
+ end
276
+
277
+ def cell_error(sheet, raw, index, key, value, reason)
278
+ CellError.new(sheet: sheet.name, row: raw.number, column_index: index, key: key, value: value, reason: reason)
279
+ end
280
+ end
281
+ end