calcsheet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE.txt +21 -0
- data/README.md +921 -0
- data/lib/calcsheet/book.rb +164 -0
- data/lib/calcsheet/cast.rb +578 -0
- data/lib/calcsheet/column.rb +117 -0
- data/lib/calcsheet/column_letter.rb +34 -0
- data/lib/calcsheet/configuration.rb +186 -0
- data/lib/calcsheet/decimal_text.rb +19 -0
- data/lib/calcsheet/entry_name.rb +28 -0
- data/lib/calcsheet/errors.rb +186 -0
- data/lib/calcsheet/excel_serial.rb +65 -0
- data/lib/calcsheet/format_detector.rb +109 -0
- data/lib/calcsheet/formula_protection.rb +31 -0
- data/lib/calcsheet/grid.rb +20 -0
- data/lib/calcsheet/header_keys.rb +66 -0
- data/lib/calcsheet/limited_inflater.rb +326 -0
- data/lib/calcsheet/merge_check.rb +41 -0
- data/lib/calcsheet/odf.rb +45 -0
- data/lib/calcsheet/option_validation.rb +160 -0
- data/lib/calcsheet/options.rb +10 -0
- data/lib/calcsheet/raw_row.rb +18 -0
- data/lib/calcsheet/raw_types.rb +15 -0
- data/lib/calcsheet/readers/csv_dialect.rb +264 -0
- data/lib/calcsheet/readers/csv_input.rb +128 -0
- data/lib/calcsheet/readers/csv_reader.rb +239 -0
- data/lib/calcsheet/readers/ods/cell_formats.rb +115 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +594 -0
- data/lib/calcsheet/readers/ods/count.rb +30 -0
- data/lib/calcsheet/readers/ods/cursor.rb +121 -0
- data/lib/calcsheet/readers/ods/patterns.rb +70 -0
- data/lib/calcsheet/readers/ods/row_seek.rb +157 -0
- data/lib/calcsheet/readers/ods/scanner.rb +302 -0
- data/lib/calcsheet/readers/ods/style_scan.rb +108 -0
- data/lib/calcsheet/readers/ods/tag_text.rb +76 -0
- data/lib/calcsheet/readers/ods_reader.rb +71 -0
- data/lib/calcsheet/readers/ox_errors.rb +40 -0
- data/lib/calcsheet/readers/references.rb +62 -0
- data/lib/calcsheet/readers/row_batches.rb +45 -0
- data/lib/calcsheet/readers/sax_sheet.rb +108 -0
- data/lib/calcsheet/readers/stream_remainder.rb +66 -0
- data/lib/calcsheet/readers/tag_scan.rb +119 -0
- data/lib/calcsheet/readers/xlsx/escapes.rb +36 -0
- data/lib/calcsheet/readers/xlsx/local_name.rb +54 -0
- data/lib/calcsheet/readers/xlsx/number_format.rb +89 -0
- data/lib/calcsheet/readers/xlsx/row_skipper.rb +162 -0
- data/lib/calcsheet/readers/xlsx/shared_strings.rb +209 -0
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +435 -0
- data/lib/calcsheet/readers/xlsx/styles.rb +156 -0
- data/lib/calcsheet/readers/xlsx/workbook_meta.rb +228 -0
- data/lib/calcsheet/readers/xlsx_reader.rb +122 -0
- data/lib/calcsheet/reading.rb +281 -0
- data/lib/calcsheet/row.rb +110 -0
- data/lib/calcsheet/sample.rb +10 -0
- data/lib/calcsheet/schema.rb +140 -0
- data/lib/calcsheet/sheet_info.rb +21 -0
- data/lib/calcsheet/sheet_names.rb +114 -0
- data/lib/calcsheet/sheet_selection.rb +58 -0
- data/lib/calcsheet/source.rb +263 -0
- data/lib/calcsheet/stream_body.rb +81 -0
- data/lib/calcsheet/time_zone.rb +96 -0
- data/lib/calcsheet/untrusted.rb +32 -0
- data/lib/calcsheet/version.rb +6 -0
- data/lib/calcsheet/write_book.rb +202 -0
- data/lib/calcsheet/write_sheet.rb +282 -0
- data/lib/calcsheet/writers/csv_writer.rb +138 -0
- data/lib/calcsheet/writers/ods_writer.rb +437 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +350 -0
- data/lib/calcsheet/writers/xml_text.rb +35 -0
- data/lib/calcsheet/writers/zip_buffered.rb +55 -0
- data/lib/calcsheet/zip_container.rb +190 -0
- data/lib/calcsheet/zip_directory.rb +202 -0
- data/lib/calcsheet/zip_end_record.rb +117 -0
- data/lib/calcsheet.rb +295 -0
- metadata +200 -0
|
@@ -0,0 +1,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
|