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,435 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ox"
|
|
4
|
+
require_relative "../../cast"
|
|
5
|
+
require_relative "../../configuration"
|
|
6
|
+
require_relative "../../errors"
|
|
7
|
+
require_relative "../../grid"
|
|
8
|
+
require_relative "../../untrusted"
|
|
9
|
+
require_relative "../sax_sheet"
|
|
10
|
+
require_relative "escapes"
|
|
11
|
+
require_relative "../references"
|
|
12
|
+
|
|
13
|
+
module Calcsheet
|
|
14
|
+
module Readers
|
|
15
|
+
module Xlsx
|
|
16
|
+
# Streams the XML of one sheet with Ox's SAX parser and hands each non-blank row to a block as a RawRow, cast
|
|
17
|
+
# with a Cast. Parsing stops at the end of +sheetData+, so what follows it (merged ranges,
|
|
18
|
+
# hyperlinks...) is never read.
|
|
19
|
+
#
|
|
20
|
+
# Rows are numbered by their +r+ attribute, counted on from the previous row when it is absent; cells are
|
|
21
|
+
# placed by the column letters of their +r+, after the previous cell when it is absent. Element names
|
|
22
|
+
# are read without their namespace prefix. Since the file is untrusted, a row numbered outside 1..1048576 or
|
|
23
|
+
# not after the previous row, and a cell beyond column XFD, raise MalformedFileError: they would otherwise
|
|
24
|
+
# drop rows, yield them out of order, or build rows of any length.
|
|
25
|
+
#
|
|
26
|
+
# The text of a cell is at most +max_cell_bytes+ long, and the value of a number, a date, a boolean, an error or
|
|
27
|
+
# a shared string index at most Cast::MAX_NUMBER_BYTES: longer ones raise MalformedFileError before they are
|
|
28
|
+
# joined or cast.
|
|
29
|
+
#
|
|
30
|
+
# The work per cell is a few callbacks with no Hash, no regexp compilation and no String allocated beyond the
|
|
31
|
+
# ones Ox hands over; everything else is kept in instance variables reset per row and cell.
|
|
32
|
+
#
|
|
33
|
+
# @api private
|
|
34
|
+
class SheetParser < Ox::Sax
|
|
35
|
+
include SaxSheet
|
|
36
|
+
|
|
37
|
+
# convert_special: false, so that the references in text reach #text as written (see References).
|
|
38
|
+
# skip: :skip_none hands every text over as written, whitespace included: Ox reports a whitespace-only text
|
|
39
|
+
# once, and drops it only before the end tag of an element with children, where no cell text is (with
|
|
40
|
+
# :skip_off it would report the whitespace of a childless +<t> </t>+ twice, doubling it).
|
|
41
|
+
OPTIONS = { skip: :skip_none, convert_special: false, symbolize: true, strip_namespace: true }.freeze
|
|
42
|
+
# The bytes of "A" and "Z".
|
|
43
|
+
LETTER_A = 65
|
|
44
|
+
LETTER_Z = 90
|
|
45
|
+
# The bit that makes an ASCII letter lowercase.
|
|
46
|
+
CASE_BIT = 32
|
|
47
|
+
# A reference of more than three letters is beyond the last column.
|
|
48
|
+
MAX_LETTERS = 3
|
|
49
|
+
# A row's +r+: digits without leading zero, at most seven (Grid::MAX_ROWS has seven).
|
|
50
|
+
ROW_NUMBER = /\A[1-9]\d{0,6}\z/
|
|
51
|
+
# A cell style index: digits only, at most ten of them (cellXfs never holds more styles).
|
|
52
|
+
STYLE_INDEX = /\A\d{1,10}\z/
|
|
53
|
+
# The most style indices whose kind #style_kind memoizes; beyond them a style is checked at each cell.
|
|
54
|
+
MAX_STYLE_KINDS = 4_096
|
|
55
|
+
# The byte of "0": an index of more than one digit starting with it is not canonical, and is not memoized.
|
|
56
|
+
ZERO = 48
|
|
57
|
+
|
|
58
|
+
# Parses +io+ and yields the rows numbered +start_row+ or later.
|
|
59
|
+
#
|
|
60
|
+
# @param io [#readpartial] the sheet XML from its start, or from a row start tag inside a +sheetData+
|
|
61
|
+
# element (see RowSkipper)
|
|
62
|
+
# @param sheet [String] the sheet name, for errors
|
|
63
|
+
# @param cast [Cast]
|
|
64
|
+
# @param types [#[], nil] the declared type per column index (see Reading)
|
|
65
|
+
# @param styles [WorkbookMeta] gives +style_kind(index)+
|
|
66
|
+
# @param strings [#call] returns the SharedStrings, called at the first +t="s"+ cell
|
|
67
|
+
# @param start_row [Integer]
|
|
68
|
+
# @param max_cell_bytes [Integer, nil] the longest text of a cell; nil for no limit
|
|
69
|
+
# @yieldparam row [RawRow]
|
|
70
|
+
# @raise [MalformedFileError] for damaged XML (the parser error is the +cause+), a shared string outside the
|
|
71
|
+
# table, a text too long, or another error of the stream (kept as +cause+)
|
|
72
|
+
# @raise [Calcsheet::Error] an error of the stream itself (a zip limit)
|
|
73
|
+
def self.parse(io, sheet:, cast:, types:, styles:, strings:, start_row: 1,
|
|
74
|
+
max_cell_bytes: Configuration::DEFAULTS[:max_cell_bytes], &block)
|
|
75
|
+
new(sheet: sheet, cast: cast, types: types, styles: styles, strings: strings, start_row: start_row,
|
|
76
|
+
max_cell_bytes: max_cell_bytes, on_row: block).parse(io)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
#
|
|
81
|
+
# The state of the parse, in instance variables so that a callback allocates nothing:
|
|
82
|
+
#
|
|
83
|
+
# - set once: +@sheet+, +@cast+, +@types+, +@styles+ (the WorkbookMeta), +@strings+ (the lambda that loads the
|
|
84
|
+
# shared strings) and +@table+ (the SharedStrings, loaded at the first +t="s"+ cell), +@start_row+,
|
|
85
|
+
# +@on_row+ (the caller's block), +@max_cell_bytes+, +@style_kinds+ (the memo of #style_kind);
|
|
86
|
+
# - across rows: +@last_number+ (the number of the last row read, 0 before the first) and +@yielding+ (the
|
|
87
|
+
# caller's block runs; see SaxSheet#parse);
|
|
88
|
+
# - the element whose attributes come: +@element+;
|
|
89
|
+
# - per row (#begin_row): +@in_row+, +@row_number+ (its +r+, nil without one), +@values+ (sparse, see
|
|
90
|
+
# SaxSheet#trim_row!), +@errors+, +@last+ (the last non-blank column, -1 for none) and +@next_column+ (the
|
|
91
|
+
# column of a cell without +r+);
|
|
92
|
+
# - per cell (+c+): +@in_cell+ (inside a row), +@column+ (from its +r+, nil without one), +@cell_type+ (its
|
|
93
|
+
# +t+), +@style+ (its +s+) and +@text+ (the text joined so far, nil before any);
|
|
94
|
+
# - per text: +@capture+ (the text of +v+, or of a +t+ of the inline string outside phonetic runs, is the
|
|
95
|
+
# cell's), +@inline+ (inside the cell's +is+), +@phonetic+ (the depth of +rPh+ runs), +@bounded+ and
|
|
96
|
+
# +@text_limit+ (the most bytes the text may join to: Cast::MAX_NUMBER_BYTES for a bounded value,
|
|
97
|
+
# +@max_cell_bytes+ for text).
|
|
98
|
+
def initialize(sheet:, cast:, types:, styles:, strings:, start_row:, max_cell_bytes:, on_row:)
|
|
99
|
+
super()
|
|
100
|
+
@max_cell_bytes = max_cell_bytes
|
|
101
|
+
@text_limit = nil
|
|
102
|
+
@bounded = false
|
|
103
|
+
@sheet = sheet
|
|
104
|
+
@cast = cast
|
|
105
|
+
@types = types
|
|
106
|
+
@styles = styles
|
|
107
|
+
@strings = strings
|
|
108
|
+
@table = nil
|
|
109
|
+
@start_row = start_row
|
|
110
|
+
@on_row = on_row
|
|
111
|
+
@yielding = false
|
|
112
|
+
@last_number = 0
|
|
113
|
+
@element = nil
|
|
114
|
+
@in_row = false
|
|
115
|
+
@in_cell = false
|
|
116
|
+
@capture = false
|
|
117
|
+
@inline = false
|
|
118
|
+
@phonetic = 0
|
|
119
|
+
# +[kind, pad]+ by the text of a valid, canonical cell style index (see #style_kind), so that each is checked
|
|
120
|
+
# once.
|
|
121
|
+
@style_kinds = {}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @api private
|
|
125
|
+
# Ox's callbacks (start_element, end_element, attr, text, cdata) drive the state described at #initialize.
|
|
126
|
+
def start_element(name)
|
|
127
|
+
@element = name
|
|
128
|
+
case name
|
|
129
|
+
when :c
|
|
130
|
+
# A cell outside a row has no row to go to.
|
|
131
|
+
@in_cell = @in_row
|
|
132
|
+
@column = nil
|
|
133
|
+
@cell_type = nil
|
|
134
|
+
@style = nil
|
|
135
|
+
@text = nil
|
|
136
|
+
# A cell nested in the inline string of another one starts outside any text.
|
|
137
|
+
@capture = false
|
|
138
|
+
@inline = false
|
|
139
|
+
when :v
|
|
140
|
+
@capture = @in_cell
|
|
141
|
+
@bounded = bounded_value?
|
|
142
|
+
@text_limit = @bounded ? Cast::MAX_NUMBER_BYTES : @max_cell_bytes
|
|
143
|
+
when :t
|
|
144
|
+
@capture = @inline && @phonetic.zero?
|
|
145
|
+
@bounded = false
|
|
146
|
+
@text_limit = @max_cell_bytes
|
|
147
|
+
when :row then begin_row
|
|
148
|
+
when :is then @inline = @in_cell
|
|
149
|
+
when :rPh then @phonetic += 1
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @api private
|
|
154
|
+
def end_element(name)
|
|
155
|
+
case name
|
|
156
|
+
when :v, :t then @capture = false
|
|
157
|
+
when :c
|
|
158
|
+
finish_cell if @in_cell
|
|
159
|
+
# The text ends with its cell, also for a cell nested in an inline string: no later text is joined to
|
|
160
|
+
# it, and @text (a shared string, or the raw value of a cast error) is never changed afterwards.
|
|
161
|
+
@text = nil
|
|
162
|
+
@capture = false
|
|
163
|
+
@inline = false
|
|
164
|
+
when :row then finish_row if @in_row
|
|
165
|
+
when :is then @inline = false
|
|
166
|
+
when :rPh then @phonetic -= 1 if @phonetic.positive?
|
|
167
|
+
when :sheetData then throw STOP
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @api private
|
|
172
|
+
def attr(name, value)
|
|
173
|
+
case @element
|
|
174
|
+
when :c
|
|
175
|
+
case name
|
|
176
|
+
# A reference is ASCII: only another one needs the UTF-8 check, which costs a method call per cell.
|
|
177
|
+
when :r then @column = column_of(value.ascii_only? ? value : attribute_utf8(value))
|
|
178
|
+
when :t then @cell_type = value
|
|
179
|
+
when :s then @style = value
|
|
180
|
+
end
|
|
181
|
+
when :row
|
|
182
|
+
@row_number = row_number(value) if name == :r
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The text of the cell, joined from its pieces (rich text runs, CDATA sections) and measured before each join.
|
|
187
|
+
# The pieces are appended to the first one, a String Ox allocated for this callback (+value+ copies it only
|
|
188
|
+
# if it were frozen), so a cell of many runs is not copied once per run. The line ends of every piece are read
|
|
189
|
+
# as XML reads them, and the references of a text piece decoded; a CDATA section keeps its references.
|
|
190
|
+
def text(value)
|
|
191
|
+
return unless @capture
|
|
192
|
+
|
|
193
|
+
value = References.line_ends(value) if value.include?("\r")
|
|
194
|
+
join(value.include?("&") ? references(value) : value)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @api private
|
|
198
|
+
def cdata(value)
|
|
199
|
+
join(value.include?("\r") ? References.line_ends(value) : value) if @capture
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def join(value)
|
|
205
|
+
if @text
|
|
206
|
+
raise text_too_long if @text_limit && @text.bytesize + value.bytesize > @text_limit
|
|
207
|
+
|
|
208
|
+
@text << value
|
|
209
|
+
else
|
|
210
|
+
raise text_too_long if @text_limit && value.bytesize > @text_limit
|
|
211
|
+
|
|
212
|
+
@text = +value
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def references(value)
|
|
217
|
+
References.decode(value)
|
|
218
|
+
rescue References::Invalid => error
|
|
219
|
+
raise malformed("the text holds #{error.message}", column_index: current_column)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Whether the value of the cell is a number, a date, a boolean, an error or a shared string index: a text
|
|
223
|
+
# bounded by Cast::MAX_NUMBER_BYTES rather than max_cell_bytes.
|
|
224
|
+
def bounded_value?
|
|
225
|
+
case @cell_type
|
|
226
|
+
when nil, "n", "s", "b", "e", "d" then true
|
|
227
|
+
else false
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def text_too_long
|
|
232
|
+
message =
|
|
233
|
+
if @bounded then "the value is longer than #{Cast::MAX_NUMBER_BYTES} bytes"
|
|
234
|
+
else "the text is longer than max_cell_bytes (#{@max_cell_bytes} bytes)"
|
|
235
|
+
end
|
|
236
|
+
malformed(message, column_index: current_column)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def begin_row
|
|
240
|
+
@in_row = true
|
|
241
|
+
@row_number = nil
|
|
242
|
+
@values = []
|
|
243
|
+
@errors = nil
|
|
244
|
+
@last = -1
|
|
245
|
+
@next_column = 0
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def finish_row
|
|
249
|
+
@in_row = false
|
|
250
|
+
number = @row_number || counted_row
|
|
251
|
+
@last_number = number
|
|
252
|
+
return if @last.negative? || number < @start_row
|
|
253
|
+
|
|
254
|
+
trim_row!
|
|
255
|
+
yield_row(number, @values, @errors)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def finish_cell
|
|
259
|
+
@in_cell = false
|
|
260
|
+
column = @column || @next_column
|
|
261
|
+
raise beyond_last_column if column > Grid::LAST_COLUMN_INDEX
|
|
262
|
+
|
|
263
|
+
@next_column = column + 1
|
|
264
|
+
return if @text.nil?
|
|
265
|
+
|
|
266
|
+
value = cell_value(column)
|
|
267
|
+
@values[column] = value unless value.nil?
|
|
268
|
+
rescue Cast::Failure => failure
|
|
269
|
+
(@errors ||= []) << [ column, error_text(failure), failure.reason ]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# The text of a failed cast: the cell's text, or for a number cell declared :integer that holds a fraction the
|
|
273
|
+
# number as read (Cast#integer_number), not the full print of a double that Excel writes.
|
|
274
|
+
def error_text(failure)
|
|
275
|
+
failure.reason == :not_integral && (@cell_type.nil? || @cell_type == "n") ? failure.value : @text
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# The cell's value, cast; before the cast, moves @last to the cell when its raw content is not blank. A
|
|
279
|
+
# string cell leaves its text in @text, for the error of a failed cast.
|
|
280
|
+
def cell_value(column)
|
|
281
|
+
type = @types && @types[column]
|
|
282
|
+
case @cell_type
|
|
283
|
+
when nil, "n"
|
|
284
|
+
@last = column if column > @last
|
|
285
|
+
kind, pad = @style ? @style_kinds[@style] || style_kind(@style, column) : @styles.style_kind(0)
|
|
286
|
+
@cast.number(utf8(@text, column), kind, type, pad: pad)
|
|
287
|
+
when "s" then string_value(@text = shared_string(column), column, type)
|
|
288
|
+
when "inlineStr", "str" then string_value(Escapes.decode!(utf8(@text, column)), column, type)
|
|
289
|
+
when "b"
|
|
290
|
+
@last = column if column > @last
|
|
291
|
+
utf8(@text, column)
|
|
292
|
+
@cast.boolean(boolean(@text), type)
|
|
293
|
+
when "e"
|
|
294
|
+
utf8(@text, column)
|
|
295
|
+
error_value(column)
|
|
296
|
+
when "d"
|
|
297
|
+
@last = column if column > @last
|
|
298
|
+
kind, = @style ? @style_kinds[@style] || style_kind(@style, column) : @styles.style_kind(0)
|
|
299
|
+
@cast.iso_date(utf8(@text, column), type, kind)
|
|
300
|
+
else
|
|
301
|
+
attribute_utf8(@cell_type)
|
|
302
|
+
string_value(utf8(@text, column), column, type)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The +[kind, pad]+ of a cell style index, checked where the style decides the value: digits only, one of the
|
|
307
|
+
# cell styles (0 also in a workbook without styles). A negative or huge index would pick another style or
|
|
308
|
+
# overflow. The kind is memoized by the canonical text of the index (no leading zero), for at most
|
|
309
|
+
# MAX_STYLE_KINDS indices: zero-padded variants of the same index would otherwise each take an entry.
|
|
310
|
+
def style_kind(style, column)
|
|
311
|
+
attribute_utf8(style)
|
|
312
|
+
count = @styles.style_count
|
|
313
|
+
index = STYLE_INDEX.match?(style) ? style.to_i : -1
|
|
314
|
+
unless index.zero? || index.between?(1, count - 1)
|
|
315
|
+
raise malformed("the cell style #{Untrusted.show(style)} is not one of the #{count} cell styles",
|
|
316
|
+
column_index: column)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
kind = @styles.style_kind(index)
|
|
320
|
+
canonical = style.getbyte(0) != ZERO || style.bytesize == 1
|
|
321
|
+
@style_kinds[style] = kind if canonical && @style_kinds.size < MAX_STYLE_KINDS
|
|
322
|
+
kind
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The value of an attribute the parser reads, as UTF-8 (see #utf8).
|
|
326
|
+
def attribute_utf8(value, row: current_row)
|
|
327
|
+
value.force_encoding(Encoding::UTF_8) unless value.encoding == Encoding::UTF_8
|
|
328
|
+
return value if value.valid_encoding?
|
|
329
|
+
|
|
330
|
+
raise malformed("an attribute value is not valid UTF-8", row: row)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def string_value(text, column, type)
|
|
334
|
+
return if text.empty?
|
|
335
|
+
|
|
336
|
+
@last = column if column > @last && !blank?(text)
|
|
337
|
+
@cast.text(text, type)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def error_value(column)
|
|
341
|
+
@last = column if column > @last
|
|
342
|
+
(@errors ||= []) << [ column, @text, :cell_error ]
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def shared_string(column)
|
|
347
|
+
@table ||= @strings.call
|
|
348
|
+
index = Integer(@text, 10, exception: false)
|
|
349
|
+
unless index && index >= 0 && index < @table.size
|
|
350
|
+
raise malformed("shared string #{Untrusted.show(@text)} is outside the table of #{@table.size} strings", column_index: column)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
text = @table.unchecked(index)
|
|
354
|
+
return text if text.valid_encoding?
|
|
355
|
+
|
|
356
|
+
raise malformed("the text is not valid UTF-8", column_index: column)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Ox tags text as UTF-8 only when the document declares it; UTF-8 is also the default of XML. Ox does not
|
|
360
|
+
# check the bytes.
|
|
361
|
+
def utf8(text, column)
|
|
362
|
+
text.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
363
|
+
return text if text.valid_encoding?
|
|
364
|
+
|
|
365
|
+
raise malformed("the text is not valid UTF-8", column_index: column)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The 0-based column of a cell reference ("AB12" -> 27); nil without letters.
|
|
369
|
+
def column_of(ref)
|
|
370
|
+
first = ref.getbyte(0)
|
|
371
|
+
second = ref.getbyte(1)
|
|
372
|
+
# The common case: one capital letter and the row digits.
|
|
373
|
+
return first - LETTER_A if second && second < LETTER_A && first >= LETTER_A && first <= LETTER_Z
|
|
374
|
+
|
|
375
|
+
index = 0
|
|
376
|
+
position = 0
|
|
377
|
+
while (byte = ref.getbyte(position))
|
|
378
|
+
byte -= CASE_BIT if byte > LETTER_Z
|
|
379
|
+
break unless byte >= LETTER_A && byte <= LETTER_Z
|
|
380
|
+
raise beyond_last_column(ref) if position == MAX_LETTERS
|
|
381
|
+
|
|
382
|
+
index = index * 26 + byte - 64
|
|
383
|
+
position += 1
|
|
384
|
+
end
|
|
385
|
+
return if position.zero?
|
|
386
|
+
raise beyond_last_column(ref) if index > Grid::MAX_COLUMNS
|
|
387
|
+
|
|
388
|
+
index - 1
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def beyond_last_column(ref = nil)
|
|
392
|
+
reference = ref ? " (#{Untrusted.show(ref)})" : ""
|
|
393
|
+
malformed("a cell is beyond the last column #{Grid::LAST_COLUMN}#{reference}")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# The number of the row given by its +r+: digits only, 1 to Grid::MAX_ROWS, after the previous row.
|
|
397
|
+
def row_number(ref)
|
|
398
|
+
number = ROW_NUMBER.match?(attribute_utf8(ref, row: last_row)) ? ref.to_i : 0
|
|
399
|
+
unless number.between?(1, Grid::MAX_ROWS)
|
|
400
|
+
raise malformed("the next row has the invalid number #{Untrusted.show(ref)} (expected 1 to #{Grid::MAX_ROWS})",
|
|
401
|
+
row: last_row)
|
|
402
|
+
end
|
|
403
|
+
if number <= @last_number
|
|
404
|
+
raise malformed("the next row is numbered #{number}, but rows must be numbered in increasing order",
|
|
405
|
+
row: last_row)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
number
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# A row without +r+ is numbered after the previous one.
|
|
412
|
+
def counted_row
|
|
413
|
+
number = @last_number + 1
|
|
414
|
+
return number if number <= Grid::MAX_ROWS
|
|
415
|
+
|
|
416
|
+
raise malformed("the next row would be numbered #{number}, beyond the last row #{Grid::MAX_ROWS}", row: last_row)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The row being read when it is known, else the last row read.
|
|
420
|
+
def current_row
|
|
421
|
+
@in_row ? (@row_number || @last_number + 1) : last_row
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def last_row
|
|
425
|
+
@last_number.positive? ? @last_number : nil
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# The column of the cell being read: of its reference, else after the previous cell.
|
|
429
|
+
def current_column
|
|
430
|
+
@column || @next_column
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ox"
|
|
4
|
+
require_relative "../../errors"
|
|
5
|
+
require_relative "../ox_errors"
|
|
6
|
+
require_relative "local_name"
|
|
7
|
+
require_relative "number_format"
|
|
8
|
+
|
|
9
|
+
module Calcsheet
|
|
10
|
+
module Readers
|
|
11
|
+
module Xlsx
|
|
12
|
+
# How each cell style of +xl/styles.xml+ reads a number: streamed with Ox's SAX parser, keeping only the
|
|
13
|
+
# custom number formats (+numFmts+, id => code) and the +numFmtId+ of each +cellXfs+ entry. Fonts,
|
|
14
|
+
# fills, borders and the rest pass by without being kept, so a styles part costs its formats, not its size.
|
|
15
|
+
#
|
|
16
|
+
# Since the file is untrusted, more than MAX_CELL_FORMATS cellXfs entries, more than MAX_NUMBER_FORMATS numFmts,
|
|
17
|
+
# a format code longer than MAX_CODE_BYTES or not valid UTF-8 raise MalformedFileError. Elements and attributes
|
|
18
|
+
# are matched by local name.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
class Styles < Ox::Sax
|
|
22
|
+
include LocalName::Memo
|
|
23
|
+
|
|
24
|
+
PART = "xl/styles.xml"
|
|
25
|
+
# The cellXfs entries, numFmts and format code bytes a styles part may have.
|
|
26
|
+
MAX_CELL_FORMATS = 1_000_000
|
|
27
|
+
MAX_NUMBER_FORMATS = 100_000
|
|
28
|
+
MAX_CODE_BYTES = 1_024
|
|
29
|
+
# More digits are no numFmtId (an xsd:unsignedInt): such an id is UNKNOWN_ID. A numFmt of it defines nothing,
|
|
30
|
+
# and an xf of it is general, so that two different over-long ids never share a format code.
|
|
31
|
+
#
|
|
32
|
+
# Any other id is read with String#to_i: a negative one ("-5") pairs only with a numFmt of the same id, and is
|
|
33
|
+
# general without one, as no built-in format has it; "-1" is UNKNOWN_ID itself, general. A value without
|
|
34
|
+
# leading digits reads as 0, like a missing numFmtId (see #add_cell_format).
|
|
35
|
+
MAX_ID_DIGITS = 10
|
|
36
|
+
UNKNOWN_ID = -1
|
|
37
|
+
# The attribute values and the codes are all the part gives: whitespace between elements is dropped.
|
|
38
|
+
OPTIONS = { skip: :skip_white, convert_special: true, symbolize: true }.freeze
|
|
39
|
+
|
|
40
|
+
# @param io [#readpartial] the styles XML, for example +ZipContainer#stream+
|
|
41
|
+
# @return [Array<Array(Symbol, Integer | nil)>] +[kind, pad]+ per cell style index (see NumberFormat)
|
|
42
|
+
# @raise [MalformedFileError] for damaged XML (the parser error is the +cause+), a root that is not a
|
|
43
|
+
# styleSheet, or a bound exceeded
|
|
44
|
+
# @raise [Calcsheet::Error] an error of the stream itself (a zip limit)
|
|
45
|
+
def self.load(io)
|
|
46
|
+
styles = new
|
|
47
|
+
begin
|
|
48
|
+
Ox.sax_parse(styles, io, **OPTIONS)
|
|
49
|
+
rescue *OxErrors::RAISED => error
|
|
50
|
+
OxErrors.raise_translated(error) { MalformedFileError.new("#{PART} cannot be parsed: #{_1}") }
|
|
51
|
+
end
|
|
52
|
+
styles.result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def initialize
|
|
57
|
+
super
|
|
58
|
+
start_memo
|
|
59
|
+
# The local name of the root element, which must be styleSheet.
|
|
60
|
+
@root = nil
|
|
61
|
+
# The local names of the open elements, from the root.
|
|
62
|
+
@path = []
|
|
63
|
+
# The element whose attributes come.
|
|
64
|
+
@element = nil
|
|
65
|
+
# The code of each custom number format id, and how many numFmt elements came.
|
|
66
|
+
@codes = {}
|
|
67
|
+
@number_formats = 0
|
|
68
|
+
# The numFmtId of each cell format, by cell style index.
|
|
69
|
+
@ids = []
|
|
70
|
+
# The first top-level cellXfs: 0 before it, 1 inside it, 2 after it.
|
|
71
|
+
@cell_xfs = 0
|
|
72
|
+
# The numFmtId and formatCode of the numFmt or xf being read.
|
|
73
|
+
@id = nil
|
|
74
|
+
@code = nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only the numFmt children of the top-level numFmts and the xf children of the first top-level cellXfs count:
|
|
78
|
+
# dxf and cellStyleXfs hold elements of the same names.
|
|
79
|
+
def start_element(name)
|
|
80
|
+
local = @local[name] || local_name(name)
|
|
81
|
+
@root ||= local
|
|
82
|
+
@cell_xfs = 1 if local == :cellXfs && @path.size == 1 && @cell_xfs.zero?
|
|
83
|
+
@path << local
|
|
84
|
+
@element = local
|
|
85
|
+
return unless local == :numFmt || local == :xf
|
|
86
|
+
|
|
87
|
+
@id = nil
|
|
88
|
+
@code = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @api private
|
|
92
|
+
def end_element(_name)
|
|
93
|
+
local = @path.pop
|
|
94
|
+
if @path.size == 2
|
|
95
|
+
add_number_format if local == :numFmt && @path.last == :numFmts
|
|
96
|
+
add_cell_format if local == :xf && @path.last == :cellXfs && @cell_xfs == 1
|
|
97
|
+
elsif @path.size == 1 && local == :cellXfs && @cell_xfs == 1
|
|
98
|
+
@cell_xfs = 2
|
|
99
|
+
end
|
|
100
|
+
@element = nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @api private
|
|
104
|
+
def attr(name, value)
|
|
105
|
+
case @element
|
|
106
|
+
when :numFmt, :xf
|
|
107
|
+
case @local[name] || local_name(name)
|
|
108
|
+
when :numFmtId then @id = number_format_id(value)
|
|
109
|
+
when :formatCode then @code = value if @element == :numFmt
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Ox reports damaged XML here instead of raising.
|
|
115
|
+
def error(message, line, column)
|
|
116
|
+
raise MalformedFileError.new("#{PART} cannot be parsed: #{message}"), cause: OxErrors.parse_error(message, line, column)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
def result
|
|
121
|
+
raise MalformedFileError, "#{PART} is not a styleSheet part" unless @root == :styleSheet
|
|
122
|
+
|
|
123
|
+
kinds = {}
|
|
124
|
+
@ids.map { |id| kinds[id] ||= NumberFormat.classify(id, @codes[id]).freeze }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def number_format_id(value)
|
|
130
|
+
value.bytesize <= MAX_ID_DIGITS ? value.to_i : UNKNOWN_ID
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def add_number_format
|
|
134
|
+
if (@number_formats += 1) > MAX_NUMBER_FORMATS
|
|
135
|
+
raise MalformedFileError, "#{PART} has more than #{MAX_NUMBER_FORMATS} number formats (numFmts)"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Ox tags text as UTF-8 only when the document declares it; UTF-8 is also the default of XML.
|
|
139
|
+
code = @code.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
140
|
+
raise MalformedFileError, "#{PART} has a number format code longer than #{MAX_CODE_BYTES} bytes" if code.bytesize > MAX_CODE_BYTES
|
|
141
|
+
raise MalformedFileError, "#{PART} has a number format code that is not valid UTF-8" unless code.valid_encoding?
|
|
142
|
+
|
|
143
|
+
# A numFmt without numFmtId (which the schema requires) is keyed as 0, as an xf without one reads.
|
|
144
|
+
@codes[@id || 0] = code unless @id == UNKNOWN_ID
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# An xf without numFmtId reads as id 0, the General format.
|
|
148
|
+
def add_cell_format
|
|
149
|
+
raise MalformedFileError, "#{PART} has more than #{MAX_CELL_FORMATS} cell formats (cellXfs)" if @ids.size == MAX_CELL_FORMATS
|
|
150
|
+
|
|
151
|
+
@ids << (@id || 0)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|