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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+ require_relative "../tag_scan"
5
+
6
+ module Calcsheet
7
+ module Readers
8
+ module Ods
9
+ # A forward-only view of the entry that finds the next tag a regexp matches, carrying its position across
10
+ # chunks, so that every byte is scanned once. +sheet+ and +row+ locate its errors.
11
+ #
12
+ # @api private
13
+ class Cursor
14
+ include TagScan
15
+
16
+ # "<" and what may be the beginning of a start or end tag's name, up to the end of the buffer.
17
+ PARTIAL_NAME = %r{\G</?[^\s>/<"'=]*\z}n
18
+
19
+ # @return [String, nil] the sheet name its errors name, once known
20
+ attr_accessor :sheet
21
+ # @return [Integer, nil] the last row passed over, for its errors
22
+ attr_accessor :row
23
+
24
+ # @param io [#readpartial] read forward only, never closed here
25
+ # @param entry [String] the entry name, for errors
26
+ def initialize(io, entry: "content.xml")
27
+ @io = io
28
+ @entry = entry
29
+ @buffer = String.new(encoding: Encoding::BINARY)
30
+ @from = 0
31
+ @discarded = 0
32
+ @sheet = nil
33
+ @row = nil
34
+ end
35
+
36
+ # @return [Integer] the bytes of the entry before the cursor
37
+ def position
38
+ @discarded + @from
39
+ end
40
+
41
+ # @return [String] the bytes read after the last tag found
42
+ def rest
43
+ @buffer.byteslice(@from, @buffer.bytesize - @from)
44
+ end
45
+
46
+ # @param pattern [Regexp] matches the opening of the wanted tags, and "<!" and "<?"
47
+ # @return [String, nil] the next wanted tag; nil at the end of the stream
48
+ # @raise [MalformedFileError] for a tag longer than MAX_TAG_BYTES, or a "<" inside an attribute value
49
+ def next_tag(pattern)
50
+ loop do
51
+ start = @buffer.byteindex(pattern, @from)
52
+ if start.nil?
53
+ discard(partial_name_start)
54
+ return unless read_more
55
+ elsif enclosing_tag?(@from, start)
56
+ raise inside_attribute
57
+ elsif (markup = markup_at(start))
58
+ case markup
59
+ when :other then @from = start + 1
60
+ when :incomplete
61
+ discard(start)
62
+ return unless read_more
63
+ else
64
+ position = skip_markup(start, *markup) or return
65
+ @from = position
66
+ end
67
+ else
68
+ return tag_at(start)
69
+ end
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # Reads on until the tag that starts at +start+ is complete. Rescans it at most once per chunk, and it is
76
+ # at most MAX_TAG_BYTES long.
77
+ def tag_at(start)
78
+ loop do
79
+ match = TAG.match(@buffer, start)
80
+ if match
81
+ raise too_long if match.end(0) - start > MAX_TAG_BYTES
82
+
83
+ @from = match.end(0)
84
+ return match[0]
85
+ end
86
+ raise too_long if @buffer.bytesize - start > MAX_TAG_BYTES
87
+
88
+ discard(start)
89
+ start = 0
90
+ return unless read_more
91
+ end
92
+ end
93
+
94
+ def too_long
95
+ located("a start tag is longer than #{MAX_TAG_BYTES} bytes")
96
+ end
97
+
98
+ # Where a wanted tag cut by the end of the buffer may begin (see TagScan#partial_tag).
99
+ # @raise [MalformedFileError] for a name longer than MAX_TAG_BYTES, or a last "<" inside the tag before it
100
+ def partial_name_start
101
+ last = partial_tag(@from, PARTIAL_NAME)
102
+ raise long_name if @buffer.bytesize - last > MAX_TAG_BYTES
103
+
104
+ last
105
+ end
106
+
107
+ def located(message)
108
+ return MalformedFileError.at(message, sheet: @sheet, row: @row) if @sheet
109
+
110
+ MalformedFileError.new("#{@entry}: #{message}", row: @row)
111
+ end
112
+
113
+ def discard(position)
114
+ @discarded += position
115
+ @from = 0
116
+ super
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../odf"
4
+
5
+ module Calcsheet
6
+ module Readers
7
+ module Ods
8
+ # The regexps of the tags the byte scanners look for (Scanner, StyleScan, RowSeek), and the names they compare,
9
+ # built for the prefixes of a document. Each tag regexp also matches "<!" and "<?", which may open a comment, a
10
+ # CDATA section or a processing instruction.
11
+ #
12
+ # @api private
13
+ class Patterns
14
+ # The elements of the data styles followed by StyleScan#data_style.
15
+ NUMBER_ELEMENTS = "(?:date-style|number-style|number|text|embedded-text|fraction|scientific-number|" \
16
+ "fill-character|hours|minutes|seconds|am-pm)"
17
+
18
+ # The tag regexps (+head+: the styles before the first table, +tables+, +rows+: the row, column, row group and
19
+ # table tags of a table), the qualified names compared with TagText#element_name (+table+, +row+, +column+,
20
+ # +style+, +table_properties+, +number_qualifier+, +qualified_rows_repeated+, +qualified_columns_repeated+),
21
+ # and the attribute regexps that TagText#attribute reads (the others).
22
+ attr_reader :head, :tables, :rows, :table, :row, :column, :style, :table_properties, :display, :family,
23
+ :style_name, :data_style_name, :parent_style_name, :table_name, :table_style, :rows_repeated, :qualified_rows_repeated,
24
+ :columns_repeated, :qualified_columns_repeated, :number_qualifier, :decimal_places, :grouping, :min_integer_digits
25
+
26
+ # @param prefixes [Hash{Symbol => String}] the prefixes of the table, style and number namespaces
27
+ def initialize(prefixes)
28
+ table = Odf.qualifier(prefixes[:table])
29
+ style = Odf.qualifier(prefixes[:style])
30
+ number = Odf.qualifier(prefixes[:number])
31
+ escaped_table = Regexp.escape(table)
32
+ escaped_style = Regexp.escape(style)
33
+ escaped_number = Regexp.escape(number)
34
+ groups = "(?:-row-group|-header-rows|-rows)"
35
+ @table = "#{table}table".b
36
+ @row = "#{table}table-row".b
37
+ @column = "#{table}table-column".b
38
+ @style = "#{style}style".b
39
+ @table_properties = "#{style}table-properties".b
40
+ @number_qualifier = number.b
41
+ @tables = Regexp.new("<(?:[!?]|/?#{escaped_table}table[\\s>/])".b, Regexp::NOENCODING)
42
+ @rows = Regexp.new("<(?:[!?]|#{escaped_table}table(?:-row|-column|#{groups})?[\\s>/]|" \
43
+ "/#{escaped_table}table#{groups}?[\\s>])".b, Regexp::NOENCODING)
44
+ @head = Regexp.new("<(?:[!?]|/?#{escaped_style}style[\\s>/]|#{escaped_style}(?:table-properties|map)[\\s>/]|" \
45
+ "/?#{escaped_number}#{NUMBER_ELEMENTS}[\\s>/]|#{escaped_table}table[\\s>/])".b, Regexp::NOENCODING)
46
+ @display = attribute("#{table}display")
47
+ @table_name = attribute("#{table}name")
48
+ @table_style = attribute("#{table}style-name")
49
+ @rows_repeated = attribute("#{table}number-rows-repeated")
50
+ @columns_repeated = attribute("#{table}number-columns-repeated")
51
+ @qualified_rows_repeated = Odf.qualified(prefixes[:table], "number-rows-repeated")
52
+ @qualified_columns_repeated = Odf.qualified(prefixes[:table], "number-columns-repeated")
53
+ @family = attribute("#{style}family")
54
+ @style_name = attribute("#{style}name")
55
+ @data_style_name = attribute("#{style}data-style-name")
56
+ @parent_style_name = attribute("#{style}parent-style-name")
57
+ @decimal_places = attribute("#{number}decimal-places")
58
+ @grouping = attribute("#{number}grouping")
59
+ @min_integer_digits = attribute("#{number}min-integer-digits")
60
+ end
61
+
62
+ private
63
+
64
+ def attribute(name)
65
+ Regexp.new("\\s#{Regexp.escape(name)}\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')".b, Regexp::NOENCODING)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+ require_relative "../../grid"
5
+ require_relative "../../untrusted"
6
+ require_relative "count"
7
+ require_relative "tag_text"
8
+
9
+ module Calcsheet
10
+ module Readers
11
+ module Ods
12
+ # Passes over the rows of a table before a wanted one with a Cursor, without parsing them: it counts the row
13
+ # start tags with their +number-rows-repeated+, keeps the start tags of the row groups that are open (at most
14
+ # MAX_GROUPS, of at most +max_column_bytes+ together) and, when a cell format may come from a column's default
15
+ # cell style, the column elements. Tables nested in the table are passed over whole.
16
+ #
17
+ # @api private
18
+ class RowSeek
19
+ include TagText
20
+
21
+ # The most row groups open around a row: the parser (Ox) refuses a deeper nesting anyway.
22
+ MAX_GROUPS = 1_000
23
+
24
+ # The column elements of a table, kept for the parser: each becomes an empty element. Only the first
25
+ # Grid::MAX_COLUMNS columns can hold cells, and the kept bytes are bounded.
26
+ class Columns
27
+ # @return [String] the kept column elements
28
+ attr_reader :xml
29
+
30
+ # @param sheet [String] the sheet name, for errors
31
+ # @param max_bytes [Integer] the most bytes the kept elements may take
32
+ def initialize(sheet, max_bytes)
33
+ @sheet = sheet
34
+ @max_bytes = max_bytes
35
+ @xml = String.new(encoding: Encoding::BINARY)
36
+ @count = 0
37
+ end
38
+
39
+ # @param tag [String] a table-column start tag
40
+ # @param repeated [Integer] its number-columns-repeated
41
+ # @raise [MalformedFileError] when the kept elements are longer than +max_bytes+
42
+ def add(tag, repeated)
43
+ return if @count >= Grid::MAX_COLUMNS
44
+
45
+ @count += repeated
46
+ @xml << (TagText.self_closing?(tag) ? tag : "#{tag.byteslice(0, tag.bytesize - 1)}/>")
47
+ return if @xml.bytesize <= @max_bytes
48
+
49
+ raise MalformedFileError.at("the columns of the sheet are longer than #{@max_bytes} bytes", sheet: @sheet)
50
+ end
51
+ end
52
+
53
+ # @param cursor [Cursor] after the start tag of the table, with the sheet name for errors
54
+ # @param patterns [Patterns]
55
+ # @param columns [Boolean] whether the column elements are kept
56
+ # @param max_column_bytes [Integer] the most bytes the kept column elements may take, and the kept row group
57
+ # start tags
58
+ def initialize(cursor, patterns, columns:, max_column_bytes:)
59
+ @cursor = cursor
60
+ @patterns = patterns
61
+ @max_bytes = max_column_bytes
62
+ @columns = Columns.new(cursor.sheet, max_column_bytes) if columns
63
+ @groups = []
64
+ @group_bytes = 0
65
+ @nested = 0
66
+ end
67
+
68
+ # @param row [Integer] the first row wanted
69
+ # @param head [String] the binary bytes the parser reads first: a declaration and the table start tag
70
+ # @return [Array(String, Integer), nil] what the parser reads up to the cursor (+head+, the kept columns, the
71
+ # open row groups, the row start tag holding +row+ and the bytes read after it), and the number of that row;
72
+ # nil when the table ends first
73
+ # @raise [MalformedFileError] for an invalid repeat count, more than MAX_GROUPS open row groups or their start
74
+ # tags longer than +max_column_bytes+, or an error of the cursor
75
+ def seek(row, head)
76
+ # Locals for what each row tag reads. The invariants of the loop: +number+ is the number of the next row
77
+ # start tag (the first row of its run), the cursor is right after the last tag found (so +cursor.rest+ starts
78
+ # after +tag+), +@groups+ holds the start tags of the row groups open there, in nesting order, and
79
+ # +@nested+ the depth inside tables nested in the table, whose rows do not count.
80
+ cursor = @cursor
81
+ patterns = @patterns
82
+ number = 1
83
+ while (tag = cursor.next_tag(patterns.rows))
84
+ name = element_name(tag)
85
+ if name == patterns.table
86
+ return if closing?(tag) && @nested.zero?
87
+
88
+ nest(tag)
89
+ elsif @nested.positive?
90
+ next
91
+ elsif name == patterns.column
92
+ # Checked also when the columns are not kept, as ContentParser checks them.
93
+ count = repeat(tag, patterns.columns_repeated, patterns.qualified_columns_repeated, nil)
94
+ @columns&.add(tag, count)
95
+ elsif name == patterns.row
96
+ count = repeat(tag, patterns.rows_repeated, patterns.qualified_rows_repeated, number)
97
+ return [ head + @columns&.xml.to_s + @groups.join + tag + cursor.rest, number ] if number + count > row
98
+
99
+ number += count
100
+ cursor.row = number - 1
101
+ else
102
+ group(tag)
103
+ end
104
+ end
105
+ nil
106
+ end
107
+
108
+ private
109
+
110
+ # A table inside the table: its rows are not the sheet's.
111
+ def nest(tag)
112
+ if closing?(tag) then @nested -= 1
113
+ elsif !self_closing?(tag) then @nested += 1
114
+ end
115
+ end
116
+
117
+ # A row group (table:table-row-group, -header-rows, -rows) opens or closes.
118
+ def group(tag)
119
+ if closing?(tag)
120
+ @group_bytes -= @groups.pop.bytesize unless @groups.empty?
121
+ elsif !self_closing?(tag)
122
+ open_group(tag)
123
+ end
124
+ end
125
+
126
+ def open_group(tag)
127
+ if @groups.size == MAX_GROUPS
128
+ raise MalformedFileError.at("the sheet has more than #{MAX_GROUPS} nested row groups", sheet: @cursor.sheet)
129
+ end
130
+
131
+ @group_bytes += tag.bytesize
132
+ if @group_bytes > @max_bytes
133
+ raise MalformedFileError.at("the row groups of the sheet are longer than #{@max_bytes} bytes", sheet: @cursor.sheet)
134
+ end
135
+
136
+ @groups << tag
137
+ end
138
+
139
+ # The repeat count of a row or column start tag, 1 without the attribute, refused like ContentParser does.
140
+ # @param pattern [Regexp] the attribute (Patterns#rows_repeated or #columns_repeated)
141
+ # @param qualified [String] its name, for the error
142
+ # @param row [Integer, nil] the number of the row, for the error; nil for a column
143
+ # @raise [MalformedFileError] unless the count is a positive number (Count.parse)
144
+ def repeat(tag, pattern, qualified, row)
145
+ value = attribute(tag, pattern)
146
+ return 1 if value.nil?
147
+
148
+ count = Count.parse(value)
149
+ return count if count&.positive?
150
+
151
+ raise MalformedFileError.at("#{qualified} must be a positive number, got #{Untrusted.show(value)}",
152
+ sheet: @cursor.sheet, row: row)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+ require_relative "../../odf"
5
+ require_relative "../../sheet_info"
6
+ require_relative "../../untrusted"
7
+ require_relative "../stream_remainder"
8
+ require_relative "cell_formats"
9
+ require_relative "cursor"
10
+ require_relative "patterns"
11
+ require_relative "row_seek"
12
+ require_relative "style_scan"
13
+ require_relative "tag_text"
14
+
15
+ module Calcsheet
16
+ module Readers
17
+ # The parts of an ODS spreadsheet (OpenDocument) that OdsReader reads: the byte scanners that find its sheets,
18
+ # rows and styles (Scanner, Cursor, StyleScan, RowSeek, with Patterns, TagText, CellFormats and Count), and the
19
+ # ContentParser of a table.
20
+ module Ods
21
+ # Finds the sheets of an ODS +content.xml+ without parsing it: every sheet is a table element of that one
22
+ # entry, so its start is found by inflating the entry from the beginning and scanning the bytes for the table
23
+ # start tag. Rows before a wanted one are passed over the same way, counting the row start tags with their
24
+ # +number-rows-repeated+. A row costs a regexp scan, not a SAX callback per element.
25
+ #
26
+ # The namespace prefixes are read from the declarations of the root element (+t:table+ is as valid as
27
+ # +table:table+), and a sheet is hidden when its automatic table style says +table:display="false"+; the
28
+ # automatic styles come before the first table and are scanned on the way. So are the data styles that change
29
+ # how a value reads: a date style that shows a time or none, and a plain zero-padding number style
30
+ # (+00000+), with the common styles of styles.xml; see #cell_formats and StyleScan. RowSeek passes over the rows.
31
+ #
32
+ # Each call inflates the entry from its start: deflate cannot seek. Comments, CDATA sections and processing
33
+ # instructions are passed over; a "<" inside the tag before a wanted tag or a markup opening usually raises
34
+ # MalformedFileError (see Cursor#enclosing_tag?; a tag cut by a chunk boundary may be passed over unchecked, ADR
35
+ # 008). Every byte is scanned once (see Cursor) and the buffer stays bounded: a wanted tag (table, row, column,
36
+ # row group, style) may be at most Cursor::MAX_TAG_BYTES long, other tags are passed over unbuffered, the part
37
+ # before the first table is at most MAX_HEAD_BYTES, a namespace prefix at most MAX_PREFIX_BYTES, and the document
38
+ # may have at most +max_sheets+ tables.
39
+ #
40
+ # @api private
41
+ class Scanner
42
+ include TagText
43
+
44
+ # The bytes before the first table of content.xml (the automatic styles), and of styles.xml, the scanner
45
+ # passes over; and the column elements and the row group start tags RowSeek keeps (each).
46
+ MAX_HEAD_BYTES = 16_777_216
47
+ # Longer prefixes would not fit in the piece of a tag the cursor keeps between chunks.
48
+ MAX_PREFIX_BYTES = 256
49
+ # An XML name without a colon (NCName), approximated with Unicode letters and digits.
50
+ PREFIX = /\A[[:alpha:]_][[:alnum:]_.\-\u00B7]*\z/
51
+ # The XML declaration a Cut starts with: content.xml is UTF-8.
52
+ DECLARATION = '<?xml version="1.0" encoding="UTF-8"?>'.b.freeze
53
+ # The namespaces whose prefixes the scanner and ContentParser use.
54
+ NAMESPACES = Odf::NAMESPACES.slice(:office, :table, :text, :style, :number, :calcext).freeze
55
+ # The first element (or a "<!", "<?" markup to pass over): the root, which declares the namespaces.
56
+ ROOT = /<(?:[!?]|[A-Za-z_])/n
57
+ # A namespace declaration of the root start tag: its prefix (none for the default namespace) and its URI.
58
+ DECLARATIONS = /\sxmlns(?::([^\s=]+))?\s*=\s*(?:"([^"]*)"|'([^']*)')/n
59
+
60
+ # The stream of one sheet from its table start tag, or from a row start tag inside it, after an XML
61
+ # declaration and the start tags of the table and the row groups the row is in: a document the parser can
62
+ # read until the end of the table. +first_row+ is the number of the first row it holds.
63
+ #
64
+ # A synthetic document: its root is the table, without the namespace declarations of the real root, which Ox
65
+ # does not check (ContentParser matches the qualified names with the prefixes Scanner#prefixes read), and the
66
+ # parser stops at the end tag of the table, before it would miss the end tags of the real ancestors.
67
+ class Cut < StreamRemainder
68
+ # @return [Integer] the number of the first row of the stream
69
+ attr_reader :first_row
70
+
71
+ # @param first_row [Integer] the number of the first row in +head+
72
+ def initialize(head, io, first_row, bound: nil)
73
+ super(head, io, bound: bound)
74
+ @first_row = first_row
75
+ end
76
+
77
+ def close
78
+ @io.close
79
+ end
80
+ end
81
+
82
+ # @param zip [ZipContainer, #stream] the opened spreadsheet
83
+ # @param entry [String] the entry holding the tables
84
+ # @param styles [String, nil] the entry holding the common styles (styles.xml), when the spreadsheet has one
85
+ # @param max_sheets [Integer, nil] the most tables #sheets accepts; nil for no limit
86
+ def initialize(zip, entry: "content.xml", styles: nil, max_sheets: nil)
87
+ @zip = zip
88
+ @entry = entry
89
+ @styles_entry = styles
90
+ @max_sheets = max_sheets
91
+ @prefixes = nil
92
+ @patterns = nil
93
+ @hidden_styles = nil
94
+ @head_formats = nil
95
+ @cell_formats = nil
96
+ @sheets = nil
97
+ end
98
+
99
+ # @return [Hash{Symbol => String}] the prefix of each namespace of NAMESPACES ("" for the default namespace);
100
+ # the conventional one for a namespace the root element does not declare
101
+ # @raise [MalformedFileError] when the document has no root element
102
+ def prefixes
103
+ walk { |cursor| read_root(cursor) } unless @prefixes
104
+ @prefixes
105
+ end
106
+
107
+ # The cell styles whose data style decides how a value reads: the automatic styles before the first table and
108
+ # the common styles of the styles entry, read once (see CellFormats). The automatic styles win.
109
+ #
110
+ # @return [Hash{String => Array(Symbol, Integer | nil)}] +[kind, pad]+ by cell style name, as
111
+ # Xlsx::WorkbookMeta#style_kind: +[:date, nil]+ or +[:datetime, nil]+ for a date style without or with a
112
+ # time, +[:general, digits]+ for a plain zero-padding number style; other styles are left out
113
+ # @raise [MalformedFileError] for a damaged styles entry or one longer than MAX_HEAD_BYTES
114
+ def cell_formats
115
+ @cell_formats ||= begin
116
+ walk { |cursor| first_table(cursor) } unless @head_formats
117
+ common = @styles_entry ? common_formats : CellFormats.new
118
+ common.merge(@head_formats).to_h
119
+ end
120
+ end
121
+
122
+ # Scans the whole entry once; the result is kept.
123
+ #
124
+ # @return [Array<SheetInfo>] the tables in document order
125
+ # @raise [MalformedFileError] for a damaged document, a sheet name that is not UTF-8 or a bound exceeded
126
+ # @raise [TooManySheetsError] for more tables than +max_sheets+
127
+ def sheets
128
+ @sheets ||= walk do |cursor|
129
+ list = []
130
+ each_table(cursor) do |tag|
131
+ if @max_sheets && list.size == @max_sheets
132
+ raise TooManySheetsError.new(limit: :max_sheets, max: @max_sheets, actual: list.size + 1)
133
+ end
134
+
135
+ list << sheet_info(tag, list.size)
136
+ end
137
+ list.freeze
138
+ end
139
+ end
140
+
141
+ # Inflates the entry from its start and stops at the table of the sheet, or at the row start tag holding
142
+ # +row+ (a repeated row holds several).
143
+ #
144
+ # @param index [Integer] the 0-based position of the sheet
145
+ # @param row [Integer] the first row wanted
146
+ # @return [Cut, nil] the stream, which the caller must close; nil when the sheet has no row +row+ or later
147
+ # @raise [MalformedFileError] when no table is found at +index+ (#sheets listed it, or it was never listed),
148
+ # for a damaged document, an invalid repeat count or a bound exceeded
149
+ def io_at_sheet(index, row: 1)
150
+ # The Cursor passes over the tables and rows before the one wanted without the max_cell_bytes bound; the
151
+ # Cut applies it to what the parser reads.
152
+ stream = @zip.stream(@entry, bound_tokens: false)
153
+ cut = nil
154
+ begin
155
+ cursor = Cursor.new(stream, entry: @entry)
156
+ cursor.sheet = @sheets && @sheets[index]&.name
157
+ tag = table_at(cursor, index) or raise MalformedFileError, "#{@entry}: there is no sheet #{index + 1}"
158
+ cursor.sheet = sheet_name(tag, index)
159
+ bound = @zip.token_bound(@entry)
160
+ cut = row <= 1 ? Cut.new(DECLARATION + tag + cursor.rest, stream, 1, bound: bound) : seek_row(cursor, stream, tag, row, bound)
161
+ rescue IOError, SystemCallError => error
162
+ raise cursor_error(cursor, "cannot be read: #{error.message}"), cause: error
163
+ ensure
164
+ stream.close unless cut
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ # Runs the block on a cursor over a new stream of the entry, which is closed afterwards. The Cursor bounds what
171
+ # it buffers, so the stream does not bound texts by max_cell_bytes: a text too long is reported by the
172
+ # ContentParser of its sheet, at its row and column.
173
+ def walk
174
+ stream = @zip.stream(@entry, bound_tokens: false)
175
+ cursor = Cursor.new(stream, entry: @entry)
176
+ yield cursor
177
+ rescue IOError, SystemCallError => error
178
+ raise cursor_error(cursor, "cannot be read: #{error.message}"), cause: error
179
+ ensure
180
+ stream&.close
181
+ end
182
+
183
+ def cursor_error(cursor, message)
184
+ sheet = cursor&.sheet
185
+ return MalformedFileError.new("#{@entry} #{message}") unless sheet
186
+
187
+ MalformedFileError.at("the sheet #{message}", sheet: sheet)
188
+ end
189
+
190
+ # Reads the root start tag, and the namespace prefixes it declares the first time.
191
+ def read_root(cursor)
192
+ @prefixes ||= declared_prefixes(root(cursor, @entry), @entry)
193
+ @patterns ||= Patterns.new(@prefixes)
194
+ end
195
+
196
+ def root(cursor, entry)
197
+ cursor.next_tag(ROOT) or raise MalformedFileError, "#{entry}: the document has no root element"
198
+ end
199
+
200
+ # @raise [MalformedFileError] for a prefix that is not an NCName of at most MAX_PREFIX_BYTES UTF-8 bytes
201
+ def declared_prefixes(tag, entry)
202
+ by_uri = {}
203
+ tag.scan(DECLARATIONS) do |prefix, double, single|
204
+ by_uri[double || single] ||= prefix ? valid_prefix(prefix, entry) : ""
205
+ end
206
+ NAMESPACES.to_h { |name, uri| [ name, by_uri.fetch(uri.b) { name.to_s.dup } ] }
207
+ end
208
+
209
+ def valid_prefix(raw, entry)
210
+ prefix = raw.dup.force_encoding(Encoding::UTF_8)
211
+ return prefix if prefix.bytesize <= MAX_PREFIX_BYTES && prefix.valid_encoding? && PREFIX.match?(prefix)
212
+
213
+ raise MalformedFileError, "#{entry}: the root element declares an invalid namespace prefix #{Untrusted.show(prefix)}"
214
+ end
215
+
216
+ # Yields the start tag of every table that is not inside another one.
217
+ def each_table(cursor)
218
+ tag = first_table(cursor)
219
+ depth = 0
220
+ while tag
221
+ if closing?(tag)
222
+ depth -= 1 if depth.positive?
223
+ else
224
+ yield tag if depth.zero?
225
+ depth += 1 unless self_closing?(tag)
226
+ end
227
+ tag = cursor.next_tag(@patterns.tables)
228
+ end
229
+ end
230
+
231
+ # @return [String, nil] the start tag of the table at +index+
232
+ def table_at(cursor, index)
233
+ each_table(cursor) do |tag|
234
+ return tag if index.zero?
235
+
236
+ index -= 1
237
+ end
238
+ nil
239
+ end
240
+
241
+ # Reads the root and the part before the first table, noting the hidden table styles and the cell formats
242
+ # the first time.
243
+ # @return [String, nil] the first table start tag
244
+ def first_table(cursor)
245
+ read_root(cursor)
246
+ return cursor.next_tag(@patterns.tables) if @hidden_styles
247
+
248
+ hidden = {}
249
+ formats = CellFormats.new
250
+ tag = StyleScan.new(cursor, @patterns, formats, hidden, max_bytes: MAX_HEAD_BYTES).scan do
251
+ "#{@entry}: the part before the first sheet is longer than #{MAX_HEAD_BYTES} bytes"
252
+ end
253
+ @hidden_styles = hidden
254
+ @head_formats = formats
255
+ tag
256
+ end
257
+
258
+ # The cell formats of the common styles, from the styles entry with its own prefixes.
259
+ def common_formats
260
+ stream = @zip.stream(@styles_entry)
261
+ cursor = Cursor.new(stream, entry: @styles_entry)
262
+ patterns = Patterns.new(declared_prefixes(root(cursor, @styles_entry), @styles_entry))
263
+ formats = CellFormats.new
264
+ StyleScan.new(cursor, patterns, formats, {}, max_bytes: MAX_HEAD_BYTES).scan { "#{@styles_entry} is longer than #{MAX_HEAD_BYTES} bytes" }
265
+ formats
266
+ rescue IOError, SystemCallError => error
267
+ raise MalformedFileError, "#{@styles_entry} cannot be read: #{error.message}"
268
+ ensure
269
+ stream&.close
270
+ end
271
+
272
+ def sheet_info(tag, index)
273
+ style = attribute(tag, @patterns.table_style)
274
+ SheetInfo.new(name: sheet_name(tag, index), index: index, hidden: !style.nil? && @hidden_styles.key?(style))
275
+ end
276
+
277
+ # The references of a name are decoded as in the cells (References): an invalid one is refused.
278
+ def sheet_name(tag, index)
279
+ name = strict_attribute(tag, @patterns.table_name)
280
+ raise MalformedFileError, "#{@entry}: sheet #{index + 1} has no name" if name.nil?
281
+ raise MalformedFileError, "#{@entry}: the name of sheet #{index + 1} is not valid UTF-8" unless name.valid_encoding?
282
+
283
+ name
284
+ rescue References::Invalid => error
285
+ raise MalformedFileError, "#{@entry}: the name of sheet #{index + 1} holds #{error.message}"
286
+ end
287
+
288
+ # The stream of the sheet from the row start tag holding +row+ (see RowSeek); the columns are kept when a cell
289
+ # format may come from their default cell style.
290
+ # @param bound [LimitedInflater::TokenBound, nil] for the Cut (ZipContainer#token_bound)
291
+ # @return [Cut, nil] nil when the table ends first
292
+ def seek_row(cursor, stream, table, row, bound)
293
+ return if self_closing?(table)
294
+
295
+ seek = RowSeek.new(cursor, @patterns, columns: !cell_formats.empty?, max_column_bytes: MAX_HEAD_BYTES)
296
+ head, first_row = seek.seek(row, DECLARATION + table)
297
+ Cut.new(head, stream, first_row, bound: bound) if head
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end