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,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "column_letter"
4
+
5
+ module Calcsheet
6
+ # A data row of a read. +values+ are in file order, or in schema order with a schema; +errors+ are the
7
+ # CellErrors of the row (always empty with +on_error: :raise+, which raises the first one instead).
8
+ class Row
9
+ # The keys of the values, shared by every row of a read: schema keys (Symbols), or header keys (Strings,
10
+ # from HeaderKeys.build) followed by the column letters of the cells beyond the header (see #cover).
11
+ #
12
+ # #cover grows the keys as wider rows come, but only appends to +names+: the key of a position never changes, so
13
+ # a row yielded earlier, narrower than +names+, still maps its values to the same keys.
14
+ #
15
+ # @api private
16
+ class Keys
17
+ # @return [Array] the key of each position in +values+
18
+ attr_reader :names
19
+
20
+ # @param names [Array] the initial keys
21
+ # @param letters [Boolean] positions beyond +names+ are keyed by their column letter (no schema)
22
+ def initialize(names, letters:)
23
+ @names = names.dup
24
+ @positions = @names.each_with_index.to_h
25
+ @letters = letters
26
+ end
27
+
28
+ # @return [Object, nil] the key of +position+ in +values+
29
+ def [](position)
30
+ @names[position]
31
+ end
32
+
33
+ # @return [Integer, nil] the position of +key+ in +values+
34
+ def position(key)
35
+ @positions[key]
36
+ end
37
+
38
+ # Adds the column letters of the positions up to +width+, when keys come from the header. A letter that is a
39
+ # key already (a header "D") is numbered like a repeated header ("D (2)", "D (3)"...), so that #to_h keeps
40
+ # every value; the earlier key keeps its position.
41
+ def cover(width)
42
+ return unless @letters
43
+
44
+ (@names.size...width).each do |position|
45
+ letter = ColumnLetter.from_index(position)
46
+ key = letter
47
+ counter = 1
48
+ key = "#{letter} (#{counter += 1})" while @positions.key?(key)
49
+ @names << key
50
+ @positions[key] = position
51
+ end
52
+ end
53
+ end
54
+
55
+ # @return [String] the sheet name
56
+ attr_reader :sheet
57
+ # @return [Integer] the 0-based position of the sheet in the workbook, hidden sheets included
58
+ attr_reader :sheet_index
59
+ # @return [Integer] the row number the user sees (an XLSX +r+, ODS rows counted, CSV records counted)
60
+ attr_reader :number
61
+ # @return [Integer, nil] CSV: the physical line where the record starts; nil for XLSX and ODS
62
+ attr_reader :line
63
+ # @return [Array] the values in file order, or in schema order with a schema. Without a schema, the trailing
64
+ # blank cells are trimmed and, in header mode, the values padded with nil to the width of the header
65
+ attr_reader :values
66
+ # @return [Array<CellError>] frozen; empty with +on_error: :raise+
67
+ attr_reader :errors
68
+
69
+ # @api private
70
+ # @param sheet [String] the sheet name
71
+ # @param sheet_index [Integer] the 0-based position of the sheet in the workbook
72
+ # @param number [Integer] the row number the user sees
73
+ # @param line [Integer, nil] the physical line where the row starts (CSV)
74
+ # @param values [Array]
75
+ # @param errors [Array<CellError>]
76
+ # @param keys [Keys]
77
+ def initialize(sheet:, sheet_index:, number:, line:, values:, errors:, keys:)
78
+ @sheet = sheet
79
+ @sheet_index = sheet_index
80
+ @number = number
81
+ @line = line
82
+ @values = values
83
+ @errors = errors.frozen? ? errors : errors.dup.freeze
84
+ @keys = keys
85
+ end
86
+
87
+ # An Integer indexes +values+ (schema order when a schema is given); anything else is a key of #to_h.
88
+ # @param index_or_key [Integer, Symbol, String] a position in +values+, or a key of #to_h
89
+ # @return [Object, nil] the value; nil for an unknown key
90
+ def [](index_or_key)
91
+ position = index_or_key.is_a?(Integer) ? index_or_key : @keys.position(index_or_key)
92
+ values[position] unless position.nil?
93
+ end
94
+
95
+ # @return [Hash] schema keys, or header keys and column letters without a schema, to values
96
+ def to_h
97
+ values.each_with_index.to_h { |value, position| [ @keys[position], value ] }
98
+ end
99
+
100
+ # @return [Boolean] whether the row has no cell error
101
+ def valid?
102
+ errors.empty?
103
+ end
104
+
105
+ # @return [String] the sheet, the number and the values
106
+ def inspect
107
+ "#<#{self.class.name} sheet=#{sheet.inspect} number=#{number} values=#{values.inspect}>"
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # The beginning of a sheet, for previews and column mapping, as Book#sample returns it.
5
+ #
6
+ # @attr_reader sheet [SheetInfo] the sheet sampled
7
+ # @attr_reader header [Row, nil] the first non-blank row, native values; nil for an empty sheet
8
+ # @attr_reader rows [Array<Row>] the next non-blank data rows, native values, at most the +rows:+ asked for (frozen)
9
+ Sample = Data.define(:sheet, :header, :rows)
10
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cast"
4
+ require_relative "column_letter"
5
+ require_relative "errors"
6
+ require_relative "grid"
7
+ require_relative "header_keys"
8
+
9
+ module Calcsheet
10
+ # Maps file columns, by 0-based position or by header name, to keys and declared types: the +columns:+ read option.
11
+ #
12
+ # @api private
13
+ class Schema
14
+ # The keys a column entry may have.
15
+ ENTRY_OPTIONS = %i[at header type required].freeze
16
+ # The schema bound to one sheet, in schema order.
17
+ #
18
+ # @attr_reader keys [Array<Symbol>] the key of each column
19
+ # @attr_reader source_indices [Array<Integer>] the 0-based file column of each one
20
+ # @attr_reader types [Array<Symbol, nil>] the declared type by file column (nil = native value)
21
+ # @attr_reader required [Array<Boolean>] whether each one is +required: true+
22
+ Resolved = Struct.new(:keys, :source_indices, :types, :required, keyword_init: true)
23
+
24
+ # Checks that every name is present among the header cells (case- and whitespace-insensitive).
25
+ # @param names [Array<String>, String] the +required_headers:+ read option
26
+ # @param header_values [Array, nil] the header row cells
27
+ # @return [void]
28
+ # @raise [ColumnNotFoundError] naming every missing one
29
+ def self.required_headers!(names, header_values)
30
+ available = Array(header_values).map { |value| HeaderKeys.normalize(value) }
31
+ missing = Array(names).reject { |name| available.include?(HeaderKeys.normalize(name)) }
32
+ return if missing.empty?
33
+
34
+ raise ColumnNotFoundError, "required header(s) not found: #{missing.map(&:inspect).join(", ")}"
35
+ end
36
+
37
+ # @param columns [Hash{Symbol => Hash}] key => { at: | header:, type:, required: }
38
+ # @param header [Boolean] whether the sheet is read with a header row
39
+ # @raise [ArgumentError] on an invalid entry
40
+ def initialize(columns, header:)
41
+ raise ArgumentError, "columns must be a non-empty Hash" unless columns.is_a?(Hash) && !columns.empty?
42
+
43
+ @header = header ? true : false
44
+ @entries = columns.map { |key, entry| validate(column_key(key), entry) }.freeze
45
+ check_keys
46
+ end
47
+
48
+ # @param header_values [Array, nil] the header row cells, nil when there is none
49
+ # @return [Resolved]
50
+ # @raise [ColumnNotFoundError] a header name matches no column
51
+ # @raise [AmbiguousColumnError] a header name matches several columns
52
+ def resolve(header_values)
53
+ normalized = header_values&.map { |value| HeaderKeys.normalize(value) }
54
+ sources = @entries.map { |entry| entry[:at] || locate(entry[:header], normalized) }
55
+ types = Array.new(sources.max + 1)
56
+ sources.each_with_index { |source, position| declare(types, source, @entries[position]) }
57
+
58
+ Resolved.new(keys: @entries.map { _1[:key] }.freeze, source_indices: sources.freeze, types: types.freeze,
59
+ required: @entries.map { _1[:required] }.freeze)
60
+ end
61
+
62
+ private
63
+
64
+ def column_key(key)
65
+ return key.to_sym if key.is_a?(Symbol) || key.is_a?(String)
66
+
67
+ raise ArgumentError, "column key #{key.inspect}: expected a Symbol or a String"
68
+ end
69
+
70
+ def validate(key, entry)
71
+ raise ArgumentError, "column #{key.inspect}: expected a Hash, got #{entry.inspect}" unless entry.is_a?(Hash)
72
+
73
+ unknown = entry.keys - ENTRY_OPTIONS
74
+ raise ArgumentError, "column #{key.inspect}: unknown option(s) #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
75
+ unless entry.key?(:at) ^ entry.key?(:header)
76
+ raise ArgumentError, "column #{key.inspect}: give exactly one of at: and header:"
77
+ end
78
+
79
+ type = entry[:type]
80
+ unless type.nil? || TYPES.include?(type)
81
+ raise ArgumentError, "column #{key.inspect}: unknown type #{type.inspect} (expected one of #{TYPES.join(", ")})"
82
+ end
83
+ unless [ nil, true, false ].include?(entry[:required])
84
+ raise ArgumentError, "column #{key.inspect}: required: must be true or false"
85
+ end
86
+
87
+ validate_source(key, entry)
88
+ { key: key, at: entry[:at], header: entry[:header], type: type, required: entry[:required] == true }
89
+ end
90
+
91
+ # +at:+ is at most Grid::LAST_COLUMN_INDEX, the last column of a spreadsheet. That also bounds the types Array,
92
+ # indexed per cell by source column, that #resolve builds densely: at most Grid::MAX_COLUMNS slots.
93
+ def validate_source(key, entry)
94
+ if entry.key?(:at)
95
+ unless entry[:at].is_a?(Integer) && entry[:at].between?(0, Grid::LAST_COLUMN_INDEX)
96
+ raise ArgumentError, "column #{key.inspect}: at: must be an Integer in 0..#{Grid::LAST_COLUMN_INDEX}, got #{entry[:at].inspect}"
97
+ end
98
+ else
99
+ raise ArgumentError, "column #{key.inspect}: header: mapping requires header: true" unless @header
100
+ unless entry[:header].is_a?(String) && !HeaderKeys.normalize(entry[:header]).empty?
101
+ raise ArgumentError, "column #{key.inspect}: header: must be a non-blank String"
102
+ end
103
+ end
104
+ end
105
+
106
+ # Two keys that are one Symbol would collapse into one value, and a position declared with two types cannot be
107
+ # cast twice (checked again on #resolve for header mappings).
108
+ def check_keys
109
+ keys = {}
110
+ types = {}
111
+ @entries.each do |entry|
112
+ raise ArgumentError, "column key #{entry[:key].inspect} is given twice (keys are compared as Symbols)" if keys.key?(entry[:key])
113
+
114
+ keys[entry[:key]] = true
115
+ declare(types, entry[:at], entry) if entry[:at]
116
+ end
117
+ end
118
+
119
+ def locate(name, normalized)
120
+ wanted = HeaderKeys.normalize(name)
121
+ matches = (normalized || []).each_index.select { |index| normalized[index] == wanted }
122
+ raise ColumnNotFoundError, "header #{name.inspect} not found" if matches.empty?
123
+ return matches.first if matches.one?
124
+
125
+ letters = matches.map { |index| ColumnLetter.from_index(index) }.join(", ")
126
+ raise AmbiguousColumnError, "header #{name.inspect} matches several columns: #{letters}"
127
+ end
128
+
129
+ def declare(types, source, entry)
130
+ type = entry[:type]
131
+ return if type.nil? && types[source].nil?
132
+
133
+ if types[source] && type && types[source] != type
134
+ raise ArgumentError, "column #{ColumnLetter.from_index(source)} is declared as both #{types[source]} and #{type}"
135
+ end
136
+
137
+ types[source] ||= type
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # A sheet of a workbook, in workbook order, as Book#sheets lists it.
5
+ #
6
+ # @attr_reader name [String] the sheet name
7
+ # @attr_reader index [Integer] its 0-based position, hidden sheets included: the Integer a +sheet:+ selector takes
8
+ # @attr_reader hidden [Boolean] whether the workbook hides it (XLSX +hidden+ or +veryHidden+, an ODS table style
9
+ # with +table:display="false"+)
10
+ SheetInfo = Data.define(:name, :index, :hidden) do
11
+ # @param hidden [Boolean] default false
12
+ def initialize(name:, index:, hidden: false)
13
+ super
14
+ end
15
+
16
+ # @return [Boolean] +hidden+
17
+ def hidden?
18
+ hidden
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # The sheet names of one written workbook: at most 31 characters counted in UTF-16 code units as Excel counts them
5
+ # (a character beyond the Basic Multilingual Plane, such as an emoji, counts twice), none of [ ] : * ? / \, not empty,
6
+ # not starting or ending with an apostrophe (Excel refuses those), and unique ignoring case. Control characters
7
+ # (tab and line breaks included, which XML attribute values turn into spaces) and U+FFFE/U+FFFF, which XML cannot
8
+ # carry, are forbidden too: XLSX and ODS store the names in attributes.
9
+ #
10
+ # @api private
11
+ class SheetNames
12
+ # UTF-16 code units, as Excel counts them (#units).
13
+ MAX_LENGTH = 31
14
+ # The characters a name cannot have; +sanitize+ replaces each with "_".
15
+ FORBIDDEN = %r{[\[\]:*?/\\\u0000-\u001F\uFFFE\uFFFF]}
16
+ # The name of a sanitized name left empty.
17
+ FALLBACK = "Sheet"
18
+
19
+ # @param sanitize [Boolean] repair invalid names and number duplicates instead of raising
20
+ def initialize(sanitize:)
21
+ @sanitize = sanitize
22
+ @taken = {}
23
+ end
24
+
25
+ # @param name [String]
26
+ # @return [String] the name to use, +name+ itself when it is valid and new
27
+ # @raise [ArgumentError] the name is invalid or taken, without sanitize
28
+ def claim(name)
29
+ raise ArgumentError, "sheet name must be a String, got #{Untrusted.show(name)}" unless name.is_a?(String)
30
+
31
+ unless valid?(name)
32
+ raise ArgumentError, "invalid sheet name #{Untrusted.show(name)} (1 to #{MAX_LENGTH} characters, an emoji counting as two, none of [ ] : * ? / \\ " \
33
+ "or control characters, not starting or ending with ')" unless @sanitize
34
+
35
+ name = sanitize(name)
36
+ end
37
+ if taken?(name)
38
+ raise ArgumentError, "duplicate sheet name #{Untrusted.show(name)}" unless @sanitize
39
+
40
+ return claim_continuation(name, 2).first
41
+ end
42
+
43
+ take(name)
44
+ end
45
+
46
+ # Claims the first free continuation of +base+ numbered +number+ or more.
47
+ # @return [Array(String, Integer)] the name and its number
48
+ def claim_continuation(base, number)
49
+ name, number = free_continuation(base, number)
50
+ [ take(name), number ]
51
+ end
52
+
53
+ # The continuation #claim_continuation would claim, without claiming it.
54
+ # @return [Array(String, Integer)] the name and its number
55
+ def free_continuation(base, number)
56
+ number += 1 while taken?(continuation(base, number))
57
+ [ continuation(base, number), number ]
58
+ end
59
+
60
+ # @return [String] "Contacts (2)", the base shortened so that the name has at most 31 characters
61
+ def continuation(base, number)
62
+ suffix = " (#{number})"
63
+ "#{shorten(base, MAX_LENGTH - suffix.length)}#{suffix}"
64
+ end
65
+
66
+ # @return [Boolean] whether +name+ is claimed, ignoring case
67
+ def taken?(name)
68
+ @taken.key?(name.downcase)
69
+ end
70
+
71
+ # Frees a name claimed for a sheet that was never started.
72
+ def release(name)
73
+ @taken.delete(name.downcase)
74
+ nil
75
+ end
76
+
77
+ private
78
+
79
+ def valid?(name)
80
+ !name.empty? && units(name) <= MAX_LENGTH && !FORBIDDEN.match?(name) && !name.start_with?("'") && !name.end_with?("'")
81
+ end
82
+
83
+ def sanitize(name)
84
+ name = shorten(name.gsub(FORBIDDEN, "_"), MAX_LENGTH).sub(/\A'/, "_").sub(/'\z/, "_")
85
+ name.empty? ? FALLBACK : name
86
+ end
87
+
88
+ # The UTF-16 code units of +text+.
89
+ def units(text)
90
+ text.each_char.sum { |char| char.ord > 0xFFFF ? 2 : 1 }
91
+ end
92
+
93
+ # The longest start of +text+ of at most +limit+ UTF-16 code units made of whole grapheme clusters, so that
94
+ # neither a surrogate pair nor a combining sequence is split.
95
+ def shorten(text, limit)
96
+ return text if units(text) <= limit
97
+
98
+ shortened = +""
99
+ used = 0
100
+ text.each_grapheme_cluster do |cluster|
101
+ used += units(cluster)
102
+ break if used > limit
103
+
104
+ shortened << cluster
105
+ end
106
+ shortened
107
+ end
108
+
109
+ def take(name)
110
+ @taken[name.downcase] = true
111
+ name
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Calcsheet
6
+ # Resolves the +sheet:+ selector of a read: hidden sheets are read only when named or indexed, or with
7
+ # +include_hidden: true+.
8
+ #
9
+ # @api private
10
+ module SheetSelection
11
+ module_function
12
+
13
+ # @param sheets [Array<SheetInfo>] every sheet of the workbook
14
+ # @param selector [nil, Integer, String, Array, :all] nil means the first visible sheet (the first sheet when
15
+ # every sheet is hidden)
16
+ # @param include_hidden [Boolean] :all includes the hidden sheets too; the default stays the first visible one.
17
+ # Without it, :all is the visible sheets, or the first sheet when every sheet is hidden, as the default.
18
+ # @return [Array<SheetInfo>] at least one sheet, without repeats, in the given order
19
+ # @raise [SheetNotFoundError] a selected sheet is missing, or the workbook has none
20
+ # @raise [ArgumentError] an invalid selector
21
+ def resolve(sheets, selector, include_hidden:)
22
+ selected =
23
+ case selector
24
+ when nil then [ sheets.find { |sheet| !sheet.hidden? } || sheets.first ].compact
25
+ when :all then include_hidden ? sheets : visible_or_first(sheets)
26
+ when Integer, String then [ find(sheets, selector) ]
27
+ when Array
28
+ raise ArgumentError, "sheet: must not be an empty Array" if selector.empty?
29
+
30
+ selector.map { |reference| find(sheets, reference) }.uniq
31
+ else raise ArgumentError, "sheet: expected an Integer, a String, an Array of them or :all, got #{selector.inspect}"
32
+ end
33
+ raise SheetNotFoundError, "the workbook has no sheet" if selected.empty?
34
+
35
+ selected
36
+ end
37
+
38
+ # @return [Array<SheetInfo>] the visible sheets; the first sheet when every sheet is hidden
39
+ def visible_or_first(sheets)
40
+ visible = sheets.reject(&:hidden?)
41
+ visible.empty? ? sheets.first(1) : visible
42
+ end
43
+
44
+ # @param reference [Integer, String] a 0-based index or a sheet name
45
+ # @return [SheetInfo]
46
+ # @raise [SheetNotFoundError] no sheet has that index or name
47
+ # @raise [ArgumentError] a reference that is neither an Integer nor a String
48
+ def find(sheets, reference)
49
+ found =
50
+ case reference
51
+ when Integer then sheets.find { |sheet| sheet.index == reference }
52
+ when String then sheets.find { |sheet| sheet.name == reference }
53
+ else raise ArgumentError, "a sheet is an Integer index or a String name, got #{reference.inspect}"
54
+ end
55
+ found or raise SheetNotFoundError, "sheet #{reference.inspect} not found"
56
+ end
57
+ end
58
+ end