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