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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+ require_relative "count"
5
+ require_relative "tag_text"
6
+
7
+ module Calcsheet
8
+ module Readers
9
+ module Ods
10
+ # Passes over the styles of an ODS entry with a Cursor, up to its first table or its end (the automatic styles
11
+ # of content.xml, the common styles of styles.xml), noting the table styles that hide a sheet and the cell and
12
+ # data styles that decide how a value reads (CellFormats): a date style that shows a time or none, and a plain
13
+ # zero-padding number style (+00000+).
14
+ #
15
+ # @api private
16
+ class StyleScan
17
+ include TagText
18
+
19
+ # The fields of a date style that show a time: a date value under it is a datetime.
20
+ TIME_FIELDS = %w[hours minutes seconds am-pm].freeze
21
+
22
+ # @param cursor [Cursor] at the start of the styles
23
+ # @param patterns [Patterns] of the entry's prefixes
24
+ # @param formats [CellFormats] where the cell and data styles go
25
+ # @param hidden [Hash{String => true}] where the names of the table styles with +table:display="false"+ go
26
+ # @param max_bytes [Integer] the most bytes of the entry the styles may take
27
+ def initialize(cursor, patterns, formats, hidden, max_bytes:)
28
+ @cursor = cursor
29
+ @patterns = patterns
30
+ @formats = formats
31
+ @hidden = hidden
32
+ @max_bytes = max_bytes
33
+ end
34
+
35
+ # @yieldreturn [String] the message of the error raised beyond +max_bytes+
36
+ # @return [String, nil] the first table start tag
37
+ # @raise [MalformedFileError] beyond +max_bytes+, or for an error of the cursor
38
+ def scan
39
+ style = nil
40
+ while (tag = @cursor.next_tag(@patterns.head))
41
+ raise MalformedFileError, yield if @cursor.position > @max_bytes
42
+
43
+ name = element_name(tag)
44
+ case name
45
+ when @patterns.table then return tag
46
+ when @patterns.style
47
+ closing = closing?(tag)
48
+ style = closing || self_closing?(tag) ? nil : table_style(tag)
49
+ cell_style(tag) unless closing
50
+ when @patterns.table_properties
51
+ @hidden[style] = true if style && attribute(tag, @patterns.display) == "false"
52
+ else data_style(tag, name)
53
+ end
54
+ end
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ # Notes a cell style with its data style and its parent.
61
+ def cell_style(tag)
62
+ return unless attribute(tag, @patterns.family) == "table-cell"
63
+
64
+ name = attribute(tag, @patterns.style_name) or return
65
+ @formats.cell_style(name, attribute(tag, @patterns.data_style_name), attribute(tag, @patterns.parent_style_name))
66
+ end
67
+
68
+ # Follows the elements of the data styles: number:date-style and number:number-style with their fields; a
69
+ # style:map inside a number style makes it conditional.
70
+ def data_style(tag, name)
71
+ closing = closing?(tag)
72
+ local = name.delete_prefix(@patterns.number_qualifier)
73
+ case local
74
+ when "date-style", "number-style"
75
+ return @formats.finish if closing
76
+
77
+ @formats.start(local == "date-style" ? :date : :number, attribute(tag, @patterns.style_name))
78
+ @formats.finish if self_closing?(tag)
79
+ else
80
+ return if closing || @formats.current.nil?
81
+
82
+ if @formats.current == :date
83
+ @formats.time! if TIME_FIELDS.include?(local)
84
+ elsif local == "number" && @formats.digits.nil? && @formats.plain?
85
+ @formats.number!(zero_padding(tag))
86
+ else
87
+ @formats.not_plain!
88
+ end
89
+ end
90
+ end
91
+
92
+ # @return [Integer, nil] the minimum integer digits of a number:number without decimals or grouping
93
+ def zero_padding(tag)
94
+ decimals = attribute(tag, @patterns.decimal_places)
95
+ return if (decimals && decimals != "0") || attribute(tag, @patterns.grouping) == "true"
96
+
97
+ count = Count.parse(attribute(tag, @patterns.min_integer_digits))
98
+ count if count && count >= 2
99
+ end
100
+
101
+ # @return [String, nil] the name of a style of the table family
102
+ def table_style(tag)
103
+ attribute(tag, @patterns.family) == "table" ? attribute(tag, @patterns.style_name) : nil
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../references"
4
+
5
+ module Calcsheet
6
+ module Readers
7
+ module Ods
8
+ # What the byte scanners of an ODS entry (Scanner, StyleScan, RowSeek) read of a tag they found: its name,
9
+ # whether it closes an element or is empty, and the decoded value of an attribute. Included, its methods are
10
+ # private helpers.
11
+ #
12
+ # @api private
13
+ module TagText
14
+ # The qualified name of a start or end tag.
15
+ NAME = %r{\A</?([^\s/>]+)}n
16
+ # A reference in an attribute value: a predefined entity, a decimal or a hexadecimal character reference.
17
+ ENTITY = /&(?:(lt|gt|amp|quot|apos)|#([0-9]{1,7})|#x(\h{1,6}));/n
18
+ # The predefined entities of XML.
19
+ ENTITIES = { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => '"', "apos" => "'" }.freeze
20
+ # The byte of "/".
21
+ SLASH = 0x2F
22
+
23
+ module_function
24
+
25
+ # @return [String, nil] the qualified name of the element of +tag+
26
+ def element_name(tag)
27
+ tag[NAME, 1]
28
+ end
29
+
30
+ # Whether +tag+ is an end tag ("</...").
31
+ def closing?(tag)
32
+ tag.getbyte(1) == SLASH
33
+ end
34
+
35
+ # Whether +tag+ is an empty-element tag ("<.../>").
36
+ def self_closing?(tag)
37
+ tag.getbyte(-2) == SLASH
38
+ end
39
+
40
+ # @param pattern [Regexp] the attribute, as Patterns builds it
41
+ # @return [String, nil] the decoded UTF-8 value of the attribute
42
+ def attribute(tag, pattern)
43
+ match = pattern.match(tag) or return
44
+ decode(match[1] || match[2])
45
+ end
46
+
47
+ # @param pattern [Regexp] the attribute, as Patterns builds it
48
+ # @return [String, nil] the value of the attribute decoded as the cells are (References), tagged UTF-8 but not
49
+ # validated
50
+ # @raise [References::Invalid] for an invalid reference, which #attribute would keep as written
51
+ def strict_attribute(tag, pattern)
52
+ match = pattern.match(tag) or return
53
+ raw = match[1] || match[2]
54
+ (raw.include?("&") ? References.decode(raw) : raw.dup).force_encoding(Encoding::UTF_8)
55
+ end
56
+
57
+ # @return [String] +raw+ with its references decoded, tagged UTF-8 but not validated (the caller checks what
58
+ # it keeps, such as a style name)
59
+ def decode(raw)
60
+ text = raw.include?("&") ? raw.gsub(ENTITY) { entity(Regexp.last_match) } : raw.dup
61
+ text.force_encoding(Encoding::UTF_8)
62
+ end
63
+
64
+ # A character reference outside Unicode or of a surrogate is left as written.
65
+ def entity(match)
66
+ return ENTITIES.fetch(match[1]).b if match[1]
67
+
68
+ code = match[2] ? match[2].to_i : match[3].to_i(16)
69
+ return match[0] if code.zero? || code > 0x10FFFF || code.between?(0xD800, 0xDFFF)
70
+
71
+ [ code ].pack("U").b
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../zip_container"
5
+ require_relative "ods/scanner"
6
+ require_relative "ods/content_parser"
7
+ require_relative "row_batches"
8
+
9
+ module Calcsheet
10
+ module Readers
11
+ # The ODS reader backend (see Reading for the contract): the tables of +content.xml+. Every sheet lives in
12
+ # that one entry, so each read inflates it from the start: Scanner finds the table, and the first row wanted,
13
+ # by scanning bytes (sampling, +start_at:+), and ContentParser parses from there to the end of the table only.
14
+ # Sampling and the header check of merged sheets therefore parse the first rows of each sheet and nothing else.
15
+ # Every pass counts against +max_uncompressed_size+, and +max_sheets+ bounds the tables, hence the
16
+ # passes a merged read can make. The rows of a repeated row or cell share their value objects (see ContentParser).
17
+ #
18
+ # @api private
19
+ class OdsReader
20
+ include RowBatches
21
+
22
+ # The entry that holds every table, and the one of the common styles.
23
+ CONTENT = "content.xml"
24
+ STYLES = "styles.xml"
25
+
26
+ # @return [Boolean] true: the zip central directory needs random access
27
+ def self.needs_seek?
28
+ true
29
+ end
30
+
31
+ # @param source [Source] a seekable source
32
+ # @param options [Options] the book options; the zip limits come from them
33
+ # @raise [MalformedFileError] when the archive cannot be read or has no content.xml
34
+ # @raise [ZipBombError] when a zip limit is exceeded
35
+ def initialize(source, options)
36
+ @zip = ZipContainer.open(source, options)
37
+ raise MalformedFileError, "the spreadsheet has no #{CONTENT}" unless @zip.entry?(CONTENT)
38
+
39
+ @scanner = Ods::Scanner.new(@zip, entry: CONTENT, styles: (STYLES if @zip.entry?(STYLES)), max_sheets: options.max_sheets)
40
+ @max_cell_bytes = options.max_cell_bytes
41
+ end
42
+
43
+ # Scans the whole content.xml the first time.
44
+ # @return [Array<SheetInfo>]
45
+ # @raise [MalformedFileError] for a damaged content.xml
46
+ # @raise [TooManySheetsError] for more tables than +max_sheets+
47
+ def sheets
48
+ @scanner.sheets
49
+ end
50
+
51
+ # Holds nothing between reads: each read closes its own stream.
52
+ def close
53
+ nil
54
+ end
55
+
56
+ private
57
+
58
+ def each_row(index, cast, types, start_row, &block)
59
+ sheet = sheets.fetch(index)
60
+ io = @scanner.io_at_sheet(index, row: start_row) or return
61
+ begin
62
+ Ods::ContentParser.parse(io, sheet: sheet.name, prefixes: @scanner.prefixes, cast: cast, types: types,
63
+ first_row: io.first_row, start_row: start_row, formats: @scanner.cell_formats,
64
+ max_cell_bytes: @max_cell_bytes, &block)
65
+ ensure
66
+ io.close
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ox"
4
+ require_relative "../errors"
5
+
6
+ module Calcsheet
7
+ module Readers
8
+ # What the Ox parsers of the readers raise instead of Ox's own errors.
9
+ #
10
+ # @api private
11
+ module OxErrors
12
+ # What Ox raises: an Ox::Error for damaged XML, a TypeError caused by what the IO raised, and an EncodingError
13
+ # for an element or attribute name that is not valid UTF-8.
14
+ RAISED = [ TypeError, Ox::Error, EncodingError ].freeze
15
+
16
+ module_function
17
+
18
+ # Raises the error of the stream itself when it is a Calcsheet::Error (a zip limit), else the
19
+ # MalformedFileError the block builds from the message of the original error (the IO's, else Ox's), with that
20
+ # error as +cause+.
21
+ #
22
+ # @param error [Exception] one of RAISED
23
+ # @yieldparam message [String]
24
+ # @yieldreturn [MalformedFileError]
25
+ def raise_translated(error)
26
+ cause = error.cause
27
+ raise cause if cause.is_a?(Calcsheet::Error)
28
+
29
+ original = cause || error
30
+ raise yield(original.message), cause: original
31
+ end
32
+
33
+ # @return [Ox::ParseError] the +cause+ of the MalformedFileError of an error Ox reports to a SAX handler's
34
+ # +error+ callback instead of raising it
35
+ def parse_error(message, line, column)
36
+ Ox::ParseError.new("#{message} at line #{line}, column #{column}")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ module Readers
5
+ # The XML references of a text (+&amp;+, +&#65;+, +&#x41;+), decoded by the gem instead of Ox for the XLSX and
6
+ # ODS parsers, and the line ends Ox hands over as written (#line_ends): Ox truncates a text at +&#0;+ and writes a reference beyond U+10FFFF as another character, so that
7
+ # a crafted cell would read as a shorter or different text. A reference to NUL, a surrogate or a code point beyond
8
+ # U+10FFFF, an unknown entity and an "&" that starts no reference raise Invalid.
9
+ #
10
+ # @api private
11
+ module References
12
+ # Raised for a text that is not valid XML; the parser turns it into a MalformedFileError at its cell.
13
+ class Invalid < StandardError; end
14
+
15
+ # Leading zeros are allowed ("&#00065;"); the digits after them are bounded, which keeps the codes small.
16
+ REFERENCE = /&(?:(lt|gt|amp|quot|apos)|#0*([0-9]{1,7})|#x0*(\h{1,6}));/n
17
+ # The predefined entities of XML.
18
+ ENTITIES = { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => '"', "apos" => "'" }.transform_values { _1.b.freeze }.freeze
19
+ # The code points a reference may name: up to U+10FFFF, not a surrogate, not NUL.
20
+ MAX_CODE_POINT = 0x10FFFF
21
+ SURROGATES = (0xD800..0xDFFF)
22
+
23
+ # The line ends that XML reads as LF (XML 1.0, 2.11): CR LF and a CR alone.
24
+ LINE_END = /\r\n?/n
25
+
26
+ module_function
27
+
28
+ # Reads the line ends of a text as XML does; called before #decode, so that a referenced CR (+&#13;+) stays.
29
+ # @param text [String] text holding a CR
30
+ # @return [String] a new String in the encoding of +text+
31
+ def line_ends(text)
32
+ text.b.gsub(LINE_END, "\n").force_encoding(text.encoding)
33
+ end
34
+
35
+ # Works on the bytes, so that a text that is not valid UTF-8 is decoded too (and refused later by its reader).
36
+ # @param text [String] text holding an "&"
37
+ # @return [String] a new String in the encoding of +text+, with the references decoded
38
+ # @raise [Invalid]
39
+ def decode(text)
40
+ count = 0
41
+ bytes = text.b
42
+ decoded = bytes.gsub(REFERENCE) do
43
+ count += 1
44
+ match = Regexp.last_match
45
+ match[1] ? ENTITIES.fetch(match[1]) : character(match[2] ? match[2].to_i : match[3].hex, match[0])
46
+ end
47
+ raise Invalid, "an \"&\" that starts no character reference" unless count == bytes.count("&")
48
+
49
+ decoded.force_encoding(text.encoding)
50
+ end
51
+
52
+ def character(code, reference)
53
+ if code.zero? || code > MAX_CODE_POINT || SURROGATES.cover?(code)
54
+ raise Invalid, "the invalid character reference #{reference}"
55
+ end
56
+
57
+ code.chr(Encoding::UTF_8).b
58
+ end
59
+ private_class_method :character
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ module Readers
5
+ # The +first_rows+ and +each_raw_batch+ of the backend contract (see Reading) for a reader that streams the rows of
6
+ # a sheet with a private +each_row(sheet_index, cast, types, start_row) { |row| ... }+; a throw or a break out of
7
+ # the block must stop the stream and release it.
8
+ #
9
+ # @api private
10
+ module RowBatches
11
+ # Thrown out of +each_row+ once #first_rows has its rows; the reader's +ensure+ releases the stream.
12
+ ENOUGH_ROWS = :calcsheet_enough_rows
13
+
14
+ # @return [Array<RawRow>] the first +count+ non-blank rows of the sheet
15
+ def first_rows(sheet_index, count, cast:, types: nil)
16
+ rows = []
17
+ return rows unless count.positive?
18
+
19
+ # The throw leaves through the reader's parser (Ox re-raises it, see SaxSheet#parse) and the +ensure+ of its
20
+ # +each_row+, which closes the stream.
21
+ catch(ENOUGH_ROWS) do
22
+ each_row(sheet_index, cast, types, 1) do |row|
23
+ rows << row
24
+ throw ENOUGH_ROWS if rows.size == count
25
+ end
26
+ end
27
+ rows
28
+ end
29
+
30
+ # @yieldparam batch [Array<RawRow>] at most +size+ rows numbered +start_row+ or later, a new Array each time
31
+ # @return [void]
32
+ def each_raw_batch(sheet_index, size:, cast:, types:, start_row: 1)
33
+ batch = []
34
+ each_row(sheet_index, cast, types, start_row) do |row|
35
+ batch << row
36
+ next if batch.size < size
37
+
38
+ yield batch
39
+ batch = []
40
+ end
41
+ yield batch unless batch.empty?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ox"
4
+ require_relative "../cast"
5
+ require_relative "../errors"
6
+ require_relative "../limited_inflater"
7
+ require_relative "../raw_row"
8
+ require_relative "../reading"
9
+ require_relative "ox_errors"
10
+
11
+ module Calcsheet
12
+ module Readers
13
+ # What the SAX parsers of one sheet (Xlsx::SheetParser, Ods::ContentParser) share: parsing until they throw STOP,
14
+ # the translation of parser errors, errors located at the sheet, row and column, and the end of a row.
15
+ #
16
+ # The including class defines OPTIONS (for Ox.sax_parse), +current_row+ and +current_column+ (where an error is
17
+ # located), and keeps +@sheet+ (the sheet name), +@on_row+ (the block rows are yielded to), +@values+, +@errors+
18
+ # (+[column, raw, reason]+ per failed cell, or nil), +@last+ (the last non-blank column, -1 for none) and
19
+ # +@in_cell+. +@yielding+ is true while the caller's block runs.
20
+ #
21
+ # @api private
22
+ module SaxSheet
23
+ # Thrown by the handler where the part it parses ends, so that the rest is never read.
24
+ STOP = :calcsheet_end_of_sheet
25
+
26
+ # @api private
27
+ # The throw of STOP, like an exception of a callback, unwinds through Ox.sax_parse: Ox runs the parse under
28
+ # rb_protect, frees its buffers and re-raises (rb_jump_tag), so nothing of the parser is left behind and the
29
+ # caller's +ensure+ closes the stream.
30
+ def parse(io)
31
+ catch(STOP) { Ox.sax_parse(self, io, **self.class::OPTIONS) }
32
+ nil
33
+ rescue *OxErrors::RAISED => error
34
+ # A TypeError of the caller's block, or one without a cause, is not the stream's.
35
+ raise if @yielding || (error.is_a?(TypeError) && error.cause.nil?)
36
+ raise token_too_long(error.cause), cause: error.cause if error.cause.is_a?(LimitedInflater::TokenTooLong)
37
+
38
+ OxErrors.raise_translated(error) { malformed("the sheet cannot be read: #{_1}") }
39
+ end
40
+
41
+ # Ox reports damaged XML here instead of raising.
42
+ # @raise [MalformedFileError] located at the row and column being read
43
+ def error(message, line, column)
44
+ raise malformed(message), cause: OxErrors.parse_error(message, line, column)
45
+ end
46
+
47
+ private
48
+
49
+ # Drops what follows the last non-blank cell: the values (a failed cast leaves the last non-blank cell nil,
50
+ # whitespace after it is trimmed) and, like CSV, the cast failures of the whitespace after it. +@errors+ is nil
51
+ # when none is left, else sorted by column.
52
+ #
53
+ # +@values+ is sparse: a cell sets only its own position, so the Array is as long as the last cell that got a
54
+ # value, nil before it where cells were empty or missing. A cell whose cast failed, or an error cell, sets
55
+ # nothing: when it is the last non-blank one and no whitespace text after it got a value, the Array is shorter
56
+ # than +@last+ + 1, and assigning nil at +@last+ extends it to that cell. Otherwise the values after +@last+
57
+ # (whitespace texts) are popped.
58
+ def trim_row!
59
+ if @values.size <= @last
60
+ @values[@last] = nil
61
+ else
62
+ @values.pop while @values.size > @last + 1
63
+ end
64
+ return unless @errors
65
+
66
+ @errors.reject! { |error| error[0] > @last }
67
+ if @errors.empty? then @errors = nil
68
+ elsif @errors.size > 1 then @errors.sort_by!(&:first)
69
+ end
70
+ end
71
+
72
+ # Hands a row to the caller's block; its errors are the block's own: +@yielding+ tells #parse not to translate
73
+ # them.
74
+ def yield_row(number, values, errors)
75
+ row = RawRow.new(number: number, values: values, errors: errors)
76
+ @yielding = true
77
+ @on_row.call(row)
78
+ @yielding = false
79
+ end
80
+
81
+ # Whitespace only, without a regexp for the common text that starts with a printable ASCII character.
82
+ def blank?(text)
83
+ byte = text.getbyte(0)
84
+ !(byte > 32 && byte < 128) && !Reading::NON_BLANK.match?(text)
85
+ end
86
+
87
+ # The texts of a boolean value in XLSX ("1", "0") and ODS ("true", "false"); both formats accept both. A case of
88
+ # String literals, which Ruby dispatches by hash, costs less per cell than a Hash or Array lookup.
89
+ def boolean(text)
90
+ case text
91
+ when "1", "true" then true
92
+ when "0", "false" then false
93
+ else raise Cast::Failure.new(:invalid_boolean, text)
94
+ end
95
+ end
96
+
97
+ # The zip stream refused a text or a tag too long for max_cell_bytes: located like the parser's own errors.
98
+ def token_too_long(error)
99
+ malformed("a text or a tag is longer than max_cell_bytes (#{error.max_cell_bytes} bytes) allows",
100
+ column_index: (current_column if @in_cell))
101
+ end
102
+
103
+ def malformed(message, column_index: nil, row: current_row)
104
+ MalformedFileError.at(message, sheet: @sheet, row: row, column_index: column_index)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tag_scan"
4
+
5
+ module Calcsheet
6
+ module Readers
7
+ # The rest of a stream after a byte scanner (Xlsx::RowSkipper, Ods::Scanner) found where the parser starts: the
8
+ # bytes already read (+head+), then the stream. Answers +readpartial+ (what Ox uses), +read+ and +eof?+; never
9
+ # hands out more bytes than asked.
10
+ #
11
+ # @api private
12
+ class StreamRemainder
13
+ # @param head [String] binary bytes handed out first
14
+ # @param io [#readpartial] the rest; never closed here
15
+ # @param bound [LimitedInflater::TokenBound, nil] checks every piece handed out, when +io+ is a stream
16
+ # opened without the bound because a byte scanner read it first (ZipContainer#token_bound)
17
+ def initialize(head, io, bound: nil)
18
+ @head = head
19
+ @io = io
20
+ @bound = bound
21
+ end
22
+
23
+ # @raise [EOFError] at the end
24
+ # @raise [LimitedInflater::TokenTooLong] when the bound is exceeded
25
+ def readpartial(length, outbuf = nil)
26
+ data = @head.empty? ? @io.readpartial(length) : take(length)
27
+ @bound&.check(data)
28
+ outbuf ? outbuf.replace(data) : data
29
+ end
30
+
31
+ # @return [String, nil] up to +length+ bytes (all with nil); nil at the end for a positive +length+
32
+ # @raise [LimitedInflater::TokenTooLong] when the bound is exceeded
33
+ def read(length = nil, outbuf = nil)
34
+ data = String.new(encoding: Encoding::BINARY)
35
+ begin
36
+ data << readpartial(length ? length - data.bytesize : TagScan::CHUNK_BYTES) while length.nil? || data.bytesize < length
37
+ rescue EOFError
38
+ nil
39
+ end
40
+ data = nil if data.empty? && length&.positive?
41
+ outbuf && data ? outbuf.replace(data) : data
42
+ end
43
+
44
+ # Reads ahead one chunk into the head to find out.
45
+ # @return [Boolean] whether nothing is left
46
+ def eof?
47
+ return false unless @head.empty?
48
+
49
+ @head = @io.readpartial(TagScan::CHUNK_BYTES).b
50
+ false
51
+ rescue EOFError
52
+ true
53
+ end
54
+
55
+ private
56
+
57
+ def take(length)
58
+ return @head.tap { @head = String.new(encoding: Encoding::BINARY) } if @head.bytesize <= length
59
+
60
+ piece = @head.byteslice(0, length)
61
+ @head = @head.byteslice(length, @head.bytesize - length)
62
+ piece
63
+ end
64
+ end
65
+ end
66
+ end