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