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,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cast"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
# A column of a written sheet, the frozen value every writer backend receives. Built with Column.from
|
|
7
|
+
# (or +new+ with the same keywords); every attribute is validated.
|
|
8
|
+
#
|
|
9
|
+
# @attr_reader header [String] the header text (frozen UTF-8), and the key of Hash rows unless +key+ is given
|
|
10
|
+
# @attr_reader type [Symbol] one of TYPES, default +:string+
|
|
11
|
+
# @attr_reader width [Numeric, nil] the column width in characters (XLSX/ODS), above 0 and at most 255; nil for
|
|
12
|
+
# the default
|
|
13
|
+
# @attr_reader scale [Integer, nil] decimals shown by XLSX/ODS for a +:decimal+ column; display only, the value is
|
|
14
|
+
# never rounded and CSV ignores it
|
|
15
|
+
# @attr_reader key [Object, nil] the key of the column in Hash rows, instead of the header
|
|
16
|
+
# @attr_reader formula_protection [Boolean] CSV prefixes text starting with a formula character with "'" (see
|
|
17
|
+
# FormulaProtection): the cells of a +:string+ column, and the header of any column; default true
|
|
18
|
+
Column = Data.define(:header, :type, :width, :scale, :key, :formula_protection) do
|
|
19
|
+
# @raise [ArgumentError] on an invalid attribute
|
|
20
|
+
def initialize(header: nil, type: :string, width: nil, scale: nil, key: nil, formula_protection: true)
|
|
21
|
+
super(header: Column.checked_header(header), type: Column.checked_type(type), width: Column.checked_width(width),
|
|
22
|
+
scale: Column.checked_scale(scale), key: key, formula_protection: Column.checked_protection(formula_protection))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A copy with +changes+, validated like +new+. Defined here because Data#with does not call +initialize+ on
|
|
26
|
+
# Ruby 3.2 (it does from 3.3); the validation must not depend on the Ruby version.
|
|
27
|
+
# @return [Column]
|
|
28
|
+
# @raise [ArgumentError] on an invalid or unknown attribute
|
|
29
|
+
def with(**changes)
|
|
30
|
+
changes.empty? ? self : self.class.new(**to_h, **changes)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Object] the key of the column in Hash rows
|
|
34
|
+
def lookup_key
|
|
35
|
+
key.nil? ? header : key
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class Column
|
|
40
|
+
# The widest column, in characters.
|
|
41
|
+
MAX_WIDTH = 255
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# @param entry [Hash, Column] { header:, type: :string, width: nil, scale: nil, key: nil, formula_protection: true }
|
|
45
|
+
# @return [Column]
|
|
46
|
+
# @raise [ArgumentError] on an invalid entry
|
|
47
|
+
def from(entry)
|
|
48
|
+
return entry if entry.is_a?(Column)
|
|
49
|
+
raise ArgumentError, "column: expected a Hash, got #{entry.inspect}" unless entry.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
unknown = entry.keys - members
|
|
52
|
+
raise ArgumentError, "column: unknown option(s) #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
|
|
53
|
+
|
|
54
|
+
new(**entry)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @api private
|
|
58
|
+
# @param text [String]
|
|
59
|
+
# @return [String, nil] +text+ in UTF-8 (a binary String is taken as UTF-8), nil when it cannot be converted
|
|
60
|
+
def utf8(text)
|
|
61
|
+
return (text.valid_encoding? ? text : nil) if text.encoding == Encoding::UTF_8
|
|
62
|
+
|
|
63
|
+
converted = text.encoding == Encoding::BINARY ? text.dup.force_encoding(Encoding::UTF_8) : text.encode(Encoding::UTF_8)
|
|
64
|
+
converted.valid_encoding? ? converted : nil
|
|
65
|
+
rescue EncodingError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
# @return [String] the header, frozen UTF-8
|
|
71
|
+
# @raise [ArgumentError] unless it is a String convertible to UTF-8
|
|
72
|
+
def checked_header(value)
|
|
73
|
+
raise ArgumentError, "column: header: must be a String, got #{value.inspect}" unless value.is_a?(String)
|
|
74
|
+
|
|
75
|
+
utf8(value)&.freeze or raise ArgumentError, "column: header: #{value.inspect} cannot be converted to UTF-8"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @api private
|
|
79
|
+
# @return [Symbol] the type
|
|
80
|
+
# @raise [ArgumentError] unless it is one of TYPES
|
|
81
|
+
def checked_type(value)
|
|
82
|
+
return value if TYPES.include?(value)
|
|
83
|
+
|
|
84
|
+
raise ArgumentError, "column: unknown type #{value.inspect} (expected one of #{TYPES.join(", ")})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @api private
|
|
88
|
+
# A width above 255 characters is beyond what XLSX and ODS show, and a BigDecimal above Float::MAX would be
|
|
89
|
+
# written as Infinity.
|
|
90
|
+
# @return [Numeric, nil] the width
|
|
91
|
+
# @raise [ArgumentError] unless it is nil or a finite real number in (0, MAX_WIDTH]
|
|
92
|
+
def checked_width(value)
|
|
93
|
+
return value if value.nil? || (value.is_a?(Numeric) && value.real? && value.finite? && value.positive? && value <= MAX_WIDTH)
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "column: width: must be a number above 0 and at most #{MAX_WIDTH}, got #{value.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @api private
|
|
99
|
+
# @return [Integer, nil] the scale
|
|
100
|
+
# @raise [ArgumentError] unless it is nil or a non-negative Integer
|
|
101
|
+
def checked_scale(value)
|
|
102
|
+
return value if value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, "column: scale: must be a non-negative Integer, got #{value.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @api private
|
|
108
|
+
# @return [Boolean] the flag
|
|
109
|
+
# @raise [ArgumentError] unless it is true or false
|
|
110
|
+
def checked_protection(value)
|
|
111
|
+
return value if value == true || value == false
|
|
112
|
+
|
|
113
|
+
raise ArgumentError, "column: formula_protection: must be true or false, got #{value.inspect}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Calcsheet
|
|
4
|
+
# Conversion between 0-based column positions and spreadsheet letters.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module ColumnLetter
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param index [Integer] 0-based position
|
|
11
|
+
# @return [String] "A".."Z", "AA"...
|
|
12
|
+
# @raise [ArgumentError] for a negative index or another type
|
|
13
|
+
def from_index(index)
|
|
14
|
+
raise ArgumentError, "column index must be a non-negative Integer: #{index.inspect}" unless index.is_a?(Integer) && index >= 0
|
|
15
|
+
|
|
16
|
+
letters = +""
|
|
17
|
+
number = index + 1
|
|
18
|
+
while number > 0
|
|
19
|
+
number, remainder = (number - 1).divmod(26)
|
|
20
|
+
letters.prepend((65 + remainder).chr)
|
|
21
|
+
end
|
|
22
|
+
letters
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param letters [String] column letters, case-insensitive
|
|
26
|
+
# @return [Integer] 0-based position
|
|
27
|
+
# @raise [ArgumentError] for anything but ASCII letters
|
|
28
|
+
def to_index(letters)
|
|
29
|
+
raise ArgumentError, "column letters must be A-Z: #{letters.inspect}" unless letters.is_a?(String) && letters.match?(/\A[A-Za-z]+\z/)
|
|
30
|
+
|
|
31
|
+
letters.upcase.each_char.reduce(0) { |number, char| number * 26 + char.ord - 64 } - 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "grid"
|
|
4
|
+
require_relative "option_validation"
|
|
5
|
+
|
|
6
|
+
module Calcsheet
|
|
7
|
+
# Process-wide defaults. Read and write them through Calcsheet.configure;
|
|
8
|
+
# every reader and writer takes a frozen Options snapshot of them.
|
|
9
|
+
#
|
|
10
|
+
# Every key of DEFAULTS has a reader and a writer (+config.max_rows = 100_000+). The writers store the value as it
|
|
11
|
+
# is: #options checks it (OptionValidation), so a wrong value raises ArgumentError at the next Calcsheet.open,
|
|
12
|
+
# Calcsheet.write or Calcsheet.stream. A book already open keeps the Options snapshot it was opened with: its
|
|
13
|
+
# reads neither see nor check later configuration changes (a read checks only the overrides it is given). What
|
|
14
|
+
# each option means, its type and where it applies are in the "Options reference" of the README.
|
|
15
|
+
class Configuration
|
|
16
|
+
# The single table of option keys and their defaults. Add new keys here (and to the README options reference,
|
|
17
|
+
# which spec/readme_spec.rb checks against this table).
|
|
18
|
+
DEFAULTS = {
|
|
19
|
+
max_file_size: 1_073_741_824,
|
|
20
|
+
max_rows: nil,
|
|
21
|
+
# The sum of the bytes inflated from a zip archive per book, every pass included: an ODS read inflates
|
|
22
|
+
# content.xml again for each sheet it reads.
|
|
23
|
+
max_uncompressed_size: 8_589_934_592,
|
|
24
|
+
max_compression_ratio: 100,
|
|
25
|
+
# The bytes any zip entry may inflate to whatever its compression ratio: verbose, repetitive XML
|
|
26
|
+
# (ODS exports) deflates beyond 100:1. Above it, max_compression_ratio applies; nil or 0 for no floor. The XLSX
|
|
27
|
+
# shared strings, kept in memory, get ZipContainer::MEMORY_RATIO_FLOOR instead.
|
|
28
|
+
compression_ratio_floor: 536_870_912,
|
|
29
|
+
max_zip_entries: 10_000,
|
|
30
|
+
# The sheets an XLSX or ODS workbook may have; each one can cost a pass over an ODS content.xml.
|
|
31
|
+
max_sheets: 1_000,
|
|
32
|
+
# The bytes of the text of one cell (an XLSX or ODS string; a CSV field once CSV::Parser has built it, so not what
|
|
33
|
+
# parsing it costs, see ADR 008), and the bytes six times this plus 64 KiB between two "<" of an XLSX or ODS
|
|
34
|
+
# part, which bound what the XML parser buffers. The text of a number or a date is at most
|
|
35
|
+
# Cast::MAX_NUMBER_BYTES whatever this is. nil for no limit.
|
|
36
|
+
max_cell_bytes: 1_048_576,
|
|
37
|
+
# :bigdecimal or :float: the class of a native fractional number (and of a fractional duration).
|
|
38
|
+
decimals: :bigdecimal,
|
|
39
|
+
time_zone: nil,
|
|
40
|
+
decimal_separator: ".",
|
|
41
|
+
thousands_separator: nil,
|
|
42
|
+
date_formats: [ "%Y-%m-%d", "%d/%m/%Y" ].freeze,
|
|
43
|
+
datetime_formats: [
|
|
44
|
+
"%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M"
|
|
45
|
+
].freeze,
|
|
46
|
+
true_words: %w[true 1].freeze,
|
|
47
|
+
false_words: %w[false 0].freeze,
|
|
48
|
+
batch_size: 1000,
|
|
49
|
+
sample_rows: 20,
|
|
50
|
+
max_rows_per_sheet: Grid::MAX_ROWS,
|
|
51
|
+
# CSV: remove the leading "'" that formula protection adds before "=", "+", "-", "@", tab or CR.
|
|
52
|
+
strip_formula_prefix: false,
|
|
53
|
+
# CSV dialect: nil detects it when reading. Writing uses col_sep (nil: ",", or ";" with excel_it) and
|
|
54
|
+
# ignores the others.
|
|
55
|
+
encoding: nil,
|
|
56
|
+
col_sep: nil,
|
|
57
|
+
quote_char: nil,
|
|
58
|
+
# CSV writing. excel_it: true presets ";", a BOM, the decimal comma and "%d/%m/%Y" dates; the preset
|
|
59
|
+
# keys default to nil (bom: false, date_format: "%Y-%m-%d", datetime_format: "%Y-%m-%d %H:%M:%S" without the
|
|
60
|
+
# preset) so that an explicit value wins over it. decimal_separator is shared with reading and defaults to
|
|
61
|
+
# ".", so the preset always writes ",".
|
|
62
|
+
row_sep: "\r\n",
|
|
63
|
+
bom: nil,
|
|
64
|
+
date_format: nil,
|
|
65
|
+
datetime_format: nil,
|
|
66
|
+
excel_it: false,
|
|
67
|
+
# Writing: replace invalid characters and number duplicates instead of raising on invalid sheet names.
|
|
68
|
+
sanitize_sheet_names: false
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
# @!attribute [rw] max_file_size
|
|
72
|
+
# @return [Integer, nil] the bytes of the source; nil for no limit
|
|
73
|
+
# @!attribute [rw] max_rows
|
|
74
|
+
# @return [Integer, nil] the rows one read may yield; nil for no limit
|
|
75
|
+
# @!attribute [rw] max_uncompressed_size
|
|
76
|
+
# @return [Integer] the bytes inflated from a zip archive, cumulative per book
|
|
77
|
+
# @!attribute [rw] max_compression_ratio
|
|
78
|
+
# @return [Numeric] inflated / compressed bytes per zip entry, beyond +compression_ratio_floor+
|
|
79
|
+
# @!attribute [rw] compression_ratio_floor
|
|
80
|
+
# @return [Integer, nil] the bytes any zip entry may inflate to whatever its ratio (the XLSX shared strings: a
|
|
81
|
+
# fixed 16 MiB instead); nil or 0 for none
|
|
82
|
+
# @!attribute [rw] max_zip_entries
|
|
83
|
+
# @return [Integer] the entries of a zip archive
|
|
84
|
+
# @!attribute [rw] max_sheets
|
|
85
|
+
# @return [Integer, nil] the sheets of an XLSX or ODS workbook; nil for no limit
|
|
86
|
+
# @!attribute [rw] max_cell_bytes
|
|
87
|
+
# @return [Integer, nil] the bytes of the text of one cell; nil for no limit
|
|
88
|
+
# @!attribute [rw] decimals
|
|
89
|
+
# @return [Symbol] +:bigdecimal+ or +:float+, the class of native fractional numbers and durations
|
|
90
|
+
# @!attribute [rw] time_zone
|
|
91
|
+
# @return [String, #local, nil] the zone of read and written times: nil (UTC), "UTC" or "Z", a UTC offset such
|
|
92
|
+
# as "+01:00", or an object responding to +local+
|
|
93
|
+
# @!attribute [rw] decimal_separator
|
|
94
|
+
# @return [String] "." or ",": of text read as a number, and of CSV output
|
|
95
|
+
# @!attribute [rw] thousands_separator
|
|
96
|
+
# @return [String, nil] a member of OptionValidation::THOUSANDS_SEPARATORS: of text read as a number
|
|
97
|
+
# @!attribute [rw] date_formats
|
|
98
|
+
# @return [Array<String>] the strptime formats of text read as +:date+ (and +:datetime+)
|
|
99
|
+
# @!attribute [rw] datetime_formats
|
|
100
|
+
# @return [Array<String>] the strptime formats of text read as +:datetime+ (and +:date+)
|
|
101
|
+
# @!attribute [rw] true_words
|
|
102
|
+
# @return [Array<String>] the text read as true by +:boolean+, case-insensitive
|
|
103
|
+
# @!attribute [rw] false_words
|
|
104
|
+
# @return [Array<String>] the text read as false by +:boolean+, case-insensitive
|
|
105
|
+
# @!attribute [rw] batch_size
|
|
106
|
+
# @return [Integer] the rows read at a time, and the default size of Book#each_batch
|
|
107
|
+
# @!attribute [rw] sample_rows
|
|
108
|
+
# @return [Integer] the default +rows:+ of Book#sample
|
|
109
|
+
# @!attribute [rw] max_rows_per_sheet
|
|
110
|
+
# @return [Integer, nil] the rows of a written XLSX or ODS sheet, header included, before the overflow
|
|
111
|
+
# @!attribute [rw] strip_formula_prefix
|
|
112
|
+
# @return [Boolean] CSV reading: remove the "'" of formula protection
|
|
113
|
+
# @!attribute [rw] encoding
|
|
114
|
+
# @return [Encoding, String, nil] CSV reading: the forced encoding; nil detects it
|
|
115
|
+
# @!attribute [rw] col_sep
|
|
116
|
+
# @return [String, nil] CSV: the forced separator when reading (nil detects it); when writing, nil is ","
|
|
117
|
+
# (";" with +excel_it+)
|
|
118
|
+
# @!attribute [rw] quote_char
|
|
119
|
+
# @return [String, nil] CSV reading: the forced quote character; nil detects it
|
|
120
|
+
# @!attribute [rw] row_sep
|
|
121
|
+
# @return [String] CSV writing: the line ending, "\r\n", "\n" or "\r"
|
|
122
|
+
# @!attribute [rw] bom
|
|
123
|
+
# @return [Boolean, nil] CSV writing: a UTF-8 byte order mark; nil follows +excel_it+
|
|
124
|
+
# @!attribute [rw] date_format
|
|
125
|
+
# @return [String, nil] CSV writing: the strftime format of dates; nil follows +excel_it+
|
|
126
|
+
# @!attribute [rw] datetime_format
|
|
127
|
+
# @return [String, nil] CSV writing: the strftime format of times; nil follows +excel_it+
|
|
128
|
+
# @!attribute [rw] excel_it
|
|
129
|
+
# @return [Boolean] CSV writing: the preset of ";", BOM, decimal comma and "%d/%m/%Y" dates
|
|
130
|
+
# @!attribute [rw] sanitize_sheet_names
|
|
131
|
+
# @return [Boolean] writing: repair invalid sheet names and number duplicates instead of raising
|
|
132
|
+
|
|
133
|
+
# One accessor per key, defined in a block so that YARD documents them from the directives above (kept in the
|
|
134
|
+
# order of DEFAULTS) instead of warning about an undocumentable splat.
|
|
135
|
+
DEFAULTS.each_key { |key| attr_accessor key }
|
|
136
|
+
|
|
137
|
+
def initialize
|
|
138
|
+
DEFAULTS.each { |key, value| public_send(:"#{key}=", self.class.copy(value)) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param overrides [Hash] per-call values that win over the configuration
|
|
142
|
+
# @return [Options] a frozen snapshot, Arrays and Strings copied and frozen
|
|
143
|
+
# @raise [ArgumentError] on an unknown key, or an invalid value (see OptionValidation)
|
|
144
|
+
def options(**overrides)
|
|
145
|
+
unknown = overrides.keys - DEFAULTS.keys
|
|
146
|
+
raise ArgumentError, "unknown option#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" if unknown.any?
|
|
147
|
+
|
|
148
|
+
values = DEFAULTS.keys.to_h { |key| [ key, overrides.fetch(key) { public_send(key) } ] }
|
|
149
|
+
Options.new(**OptionValidation.check!(values.transform_values { self.class.copy(_1) }))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @api private
|
|
153
|
+
# Duplicates arrays and strings and freezes them, so that neither the defaults
|
|
154
|
+
# nor an issued Options can be altered through a shared value.
|
|
155
|
+
# @return [Object] +value+, or its frozen copy
|
|
156
|
+
def self.copy(value)
|
|
157
|
+
case value
|
|
158
|
+
when Array then value.map { copy(_1) }.freeze
|
|
159
|
+
when String then value.dup.freeze
|
|
160
|
+
else value
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class << self
|
|
166
|
+
# The process-wide configuration, created at the first call. That creation is not thread-safe: call
|
|
167
|
+
# Calcsheet.configure (or this) once at boot, before threads read or write.
|
|
168
|
+
# @return [Configuration]
|
|
169
|
+
def config
|
|
170
|
+
@config ||= Configuration.new
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @yieldparam config [Configuration] the process-wide configuration, changed in place
|
|
174
|
+
# @return [Object] the block's value
|
|
175
|
+
def configure
|
|
176
|
+
yield config
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Restores the defaults: the next Calcsheet.config is a new Configuration. Options already issued keep their
|
|
180
|
+
# values.
|
|
181
|
+
# @return [nil]
|
|
182
|
+
def reset_config!
|
|
183
|
+
@config = nil
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
# The text of a decimal as the writers write it and Cast reads a number as a :string: the exact value, without
|
|
7
|
+
# exponent, and without the ".0" of an integral BigDecimal. WriteSheet.plain_length measures it without building it.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module DecimalText
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @param value [Integer, BigDecimal] a finite number
|
|
14
|
+
# @return [String] "12.5", "100", "-0.001"
|
|
15
|
+
def plain(value)
|
|
16
|
+
value.is_a?(Integer) ? value.to_s : value.to_s("F").delete_suffix(".0")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "untrusted"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
# Zip entry names as error messages show them and as entries are compared. The names come from the archive, so
|
|
7
|
+
# they are untrusted: shown inspected, so that control characters and invalid bytes are escaped, and cut to
|
|
8
|
+
# Untrusted::MAX_CHARACTERS.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module EntryName
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param name [String, nil]
|
|
15
|
+
# @return [String] the name inspected, with "..." after it when it was cut
|
|
16
|
+
def show(name)
|
|
17
|
+
Untrusted.show(name.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The name entries are compared by: "\\" read as "/" (rubyzip reads the backslashes of stored names so) and
|
|
21
|
+
# without leading "/".
|
|
22
|
+
# @param name [String, nil]
|
|
23
|
+
# @return [String]
|
|
24
|
+
def normalize(name)
|
|
25
|
+
name.to_s.tr("\\", "/").sub(%r{\A/+}, "")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "column_letter"
|
|
4
|
+
require_relative "untrusted"
|
|
5
|
+
|
|
6
|
+
module Calcsheet
|
|
7
|
+
# Root of every error the gem raises. Invalid arguments and options raise ArgumentError instead, and a closed book
|
|
8
|
+
# IOError.
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
# @api private
|
|
11
|
+
# Where in a workbook an error happened, as messages begin: "sheet 'Data', row 2, column B (price)", with the
|
|
12
|
+
# sheet name and the column name cut (Untrusted.cut) and only what is known.
|
|
13
|
+
# @param column_name [String, Symbol, nil] shown after the column letter
|
|
14
|
+
# @return [String]
|
|
15
|
+
def self.location(sheet, row: nil, column_index: nil, column_name: nil)
|
|
16
|
+
text = +"sheet '#{Untrusted.cut(sheet)}'"
|
|
17
|
+
text << ", row #{row}" if row
|
|
18
|
+
return text if column_index.nil?
|
|
19
|
+
|
|
20
|
+
text << ", column #{ColumnLetter.from_index(column_index)}"
|
|
21
|
+
column_name.nil? ? text : text << " (#{Untrusted.cut(column_name)})"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The file cannot be read or written in that format: an XLS or encrypted workbook (OLE2), a zip archive that is
|
|
26
|
+
# neither XLSX nor ODS (or looks like both and its name does not say which), an encrypted zip entry or one of
|
|
27
|
+
# another compression method, or a format without a backend.
|
|
28
|
+
class UnsupportedFormatError < Error; end
|
|
29
|
+
|
|
30
|
+
# The file is damaged. The parser error is kept as +cause+.
|
|
31
|
+
class MalformedFileError < Error
|
|
32
|
+
# @return [String, nil] the sheet name, when the damage is in a sheet
|
|
33
|
+
attr_reader :sheet
|
|
34
|
+
# @return [Integer, nil] the row number the user sees, when known
|
|
35
|
+
attr_reader :row
|
|
36
|
+
# @return [Integer, nil] the 0-based column, when known
|
|
37
|
+
attr_reader :column_index
|
|
38
|
+
|
|
39
|
+
# @api private
|
|
40
|
+
# @return [MalformedFileError] with +message+ after the location of the sheet, row and column (Error.location)
|
|
41
|
+
def self.at(message, sheet:, row: nil, column_index: nil)
|
|
42
|
+
text = location(sheet, row: row, column_index: column_index)
|
|
43
|
+
new("#{text}: #{message}", sheet: sheet, row: row, column_index: column_index)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param message [String] the full message, location included
|
|
47
|
+
def initialize(message = "malformed file", sheet: nil, row: nil, column_index: nil)
|
|
48
|
+
super(message)
|
|
49
|
+
@sheet = sheet
|
|
50
|
+
@row = row
|
|
51
|
+
@column_index = column_index
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A configured safety limit was exceeded.
|
|
56
|
+
class LimitExceededError < Error
|
|
57
|
+
# @return [Symbol] the option of the limit: +:max_file_size+, +:max_rows+, +:max_sheets+,
|
|
58
|
+
# +:max_uncompressed_size+, +:max_compression_ratio+ or +:max_zip_entries+
|
|
59
|
+
attr_reader :limit
|
|
60
|
+
# @return [Numeric] the value of the option
|
|
61
|
+
attr_reader :max
|
|
62
|
+
# @return [Numeric] the value observed when the limit was found exceeded: for +:max_rows+ and the sheets of an
|
|
63
|
+
# ODS (+:max_sheets+), the first count beyond +max+; for +:max_zip_entries+ and the sheets of an XLSX, the total
|
|
64
|
+
# the file declares; for +:max_file_size+, the size of the source, or the bytes read so far from a stream; for
|
|
65
|
+
# +:max_uncompressed_size+, the bytes inflated so far; for +:max_compression_ratio+, the ratio of the bytes
|
|
66
|
+
# inflated so far to the compressed size, rounded up
|
|
67
|
+
attr_reader :actual
|
|
68
|
+
|
|
69
|
+
# @param message [String, nil] nil builds "limit exceeded: actual (maximum max)"
|
|
70
|
+
def initialize(message = nil, limit: nil, max: nil, actual: nil)
|
|
71
|
+
@limit = limit
|
|
72
|
+
@max = max
|
|
73
|
+
@actual = actual
|
|
74
|
+
super(message || "#{limit} exceeded: #{actual} (maximum #{max})")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The source is larger than +max_file_size+ (+limit+ +:max_file_size+). Raised when the source is opened, or, for
|
|
79
|
+
# a stream read without a copy, as soon as the bytes read pass the limit.
|
|
80
|
+
class FileTooLargeError < LimitExceededError; end
|
|
81
|
+
# A read yielded more rows than +max_rows+ (+limit+ +:max_rows+): raised at the first row beyond it, after the
|
|
82
|
+
# rows before it were yielded (with Book#each_batch, the complete batches before it).
|
|
83
|
+
class TooManyRowsError < LimitExceededError; end
|
|
84
|
+
# An XLSX or ODS workbook has more sheets than +max_sheets+ (+limit+ +:max_sheets+).
|
|
85
|
+
class TooManySheetsError < LimitExceededError; end
|
|
86
|
+
# A zip archive exceeds a zip-bomb limit: +:max_zip_entries+ (checked before its central directory is loaded),
|
|
87
|
+
# +:max_compression_ratio+ (per entry, beyond +compression_ratio_floor+) or +:max_uncompressed_size+ (the bytes
|
|
88
|
+
# inflated per book, every pass included). The ratio and the size count the bytes actually inflated.
|
|
89
|
+
class ZipBombError < LimitExceededError; end
|
|
90
|
+
|
|
91
|
+
# A sheet named or indexed by +sheet:+ or +start_at:+ that the workbook does not have, or a workbook without any
|
|
92
|
+
# sheet. Raised by the read method, before any row.
|
|
93
|
+
class SheetNotFoundError < Error; end
|
|
94
|
+
|
|
95
|
+
# The header row of one or more sheets differs from the expected one.
|
|
96
|
+
class HeaderMismatchError < Error
|
|
97
|
+
# @return [Array<Hash>] entries of the form { sheet:, expected:, actual: }: the sheet name, the header values of
|
|
98
|
+
# the first non-empty sheet and those of the differing one (see MergeCheck)
|
|
99
|
+
attr_reader :mismatches
|
|
100
|
+
|
|
101
|
+
# @param message [String, nil] nil names the differing sheets
|
|
102
|
+
def initialize(message = nil, mismatches: [])
|
|
103
|
+
@mismatches = mismatches
|
|
104
|
+
super(message || "header mismatch in sheet(s) #{mismatches.map { Untrusted.show(_1[:sheet]) }.join(", ")}")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A +header:+ mapping of a column schema, or a name of +required_headers:+, matches no header cell. Raised by the
|
|
109
|
+
# read before its first row is yielded.
|
|
110
|
+
class ColumnNotFoundError < Error; end
|
|
111
|
+
# A +header:+ mapping of a column schema matches several header cells. Raised by the read before its first
|
|
112
|
+
# row is yielded.
|
|
113
|
+
class AmbiguousColumnError < Error; end
|
|
114
|
+
|
|
115
|
+
# A cell could not be read or converted. Raised with +on_error: :raise+ and
|
|
116
|
+
# the value object collected in +row.errors+ otherwise.
|
|
117
|
+
class CellError < Error
|
|
118
|
+
# @return [String] the sheet name
|
|
119
|
+
attr_reader :sheet
|
|
120
|
+
# @return [Integer] the row number the user sees
|
|
121
|
+
attr_reader :row
|
|
122
|
+
# @return [Integer] the 0-based column in the file
|
|
123
|
+
attr_reader :column_index
|
|
124
|
+
# @return [Symbol, nil] the schema key of the column; nil without a schema
|
|
125
|
+
attr_reader :key
|
|
126
|
+
# @return [String, nil] the raw text of the cell (the error text, such as "#DIV/0!", for +:cell_error+); nil for
|
|
127
|
+
# +:required+
|
|
128
|
+
attr_reader :value
|
|
129
|
+
# @return [Symbol] why: +:invalid_integer+, +:not_integral+, +:invalid_decimal+, +:invalid_date+,
|
|
130
|
+
# +:invalid_datetime+, +:invalid_boolean+ (a cast failed, see Cast::Failure), +:cell_error+ (an error cell of
|
|
131
|
+
# XLSX or ODS) or +:required+ (a blank cell of a +required: true+ column)
|
|
132
|
+
attr_reader :reason
|
|
133
|
+
|
|
134
|
+
# @api private
|
|
135
|
+
def initialize(sheet:, row:, column_index:, reason:, key: nil, value: nil)
|
|
136
|
+
@sheet = sheet
|
|
137
|
+
@row = row
|
|
138
|
+
@column_index = column_index
|
|
139
|
+
@key = key
|
|
140
|
+
@value = value
|
|
141
|
+
@reason = reason
|
|
142
|
+
super(build_message)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [String, nil] the spreadsheet column letters, "A".."Z", "AA"...; nil without a column index
|
|
146
|
+
def column_letter
|
|
147
|
+
ColumnLetter.from_index(column_index) unless column_index.nil?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def build_message
|
|
153
|
+
text = reason.to_s.tr("_", " ")
|
|
154
|
+
text = "#{text} #{Untrusted.show(value)}" unless value.nil?
|
|
155
|
+
"#{Error.location(sheet, row: row, column_index: column_index, column_name: key)}: #{text}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The output could not be written: a value that does not fit its column type or the format (raised before anything
|
|
160
|
+
# of its row is written, so the sheet stays usable), a second sheet in CSV, or a book used on after a sheet block
|
|
161
|
+
# raised.
|
|
162
|
+
class WriteError < Error
|
|
163
|
+
# @return [String, nil] the sheet the row would be written to
|
|
164
|
+
attr_reader :sheet
|
|
165
|
+
# @return [Integer, nil] the number of the row in that sheet, header included
|
|
166
|
+
attr_reader :row
|
|
167
|
+
# @return [Integer, nil] the 0-based column
|
|
168
|
+
attr_reader :column_index
|
|
169
|
+
|
|
170
|
+
# @api private
|
|
171
|
+
# @param header [String, nil] the header of the column, shown after its letter
|
|
172
|
+
# @return [WriteError] with +message+ after the location of the sheet, row and column (Error.location)
|
|
173
|
+
def self.at(message, sheet:, row: nil, column_index: nil, header: nil)
|
|
174
|
+
text = location(sheet, row: row, column_index: column_index, column_name: header)
|
|
175
|
+
new("#{text}: #{message}", sheet: sheet, row: row, column_index: column_index)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param message [String] the full message, location included
|
|
179
|
+
def initialize(message = "cannot write the file", sheet: nil, row: nil, column_index: nil)
|
|
180
|
+
super(message)
|
|
181
|
+
@sheet = sheet
|
|
182
|
+
@row = row
|
|
183
|
+
@column_index = column_index
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
# Excel's serial dates, read by Cast and written by Writers::XlsxWriter: the number of days since 1899-12-30 (the
|
|
7
|
+
# 1900 date system) or since 1904-01-01 (the 1904 system of a workbook with +date1904+), a fraction of a day for the
|
|
8
|
+
# time. Excel keeps Lotus 1-2-3's 1900-02-29, a day that never existed, so the serials before it are one more than
|
|
9
|
+
# the days since 1899-12-30: serial 1 is 1900-01-01. The last date is 9999-12-31.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module ExcelSerial
|
|
13
|
+
SECONDS_PER_DAY = 86_400
|
|
14
|
+
# Day 0 of the 1900 system from serial 60 on: the phantom day reads as 1900-02-28, serial 61 is 1900-03-01.
|
|
15
|
+
EPOCH_1900 = Date.new(1899, 12, 30)
|
|
16
|
+
# Day 0 of the 1904 system, which has no phantom day.
|
|
17
|
+
EPOCH_1904 = Date.new(1904, 1, 1)
|
|
18
|
+
# The serial of the phantom 1900-02-29; the serials before it count from 1899-12-31.
|
|
19
|
+
PHANTOM_DAY_SERIAL = 60
|
|
20
|
+
# Day 0 of the serials before the phantom day: serial 1 is 1900-01-01.
|
|
21
|
+
EPOCH_BEFORE_PHANTOM_DAY = Date.new(1899, 12, 31)
|
|
22
|
+
# 1900-03-01 is 61 days after 1899-12-30, and has serial 61: from it on, a serial is the number of days.
|
|
23
|
+
MARCH_1900_DAYS = 61
|
|
24
|
+
# The serial of 9999-12-31 in the 1900 system, and in the 1904 system (1462 days fewer): a 1904 serial beyond it
|
|
25
|
+
# would be a year Excel does not have, so it is refused like a 1900 one.
|
|
26
|
+
MAX_SERIAL = 2_958_465
|
|
27
|
+
MAX_SERIAL_1904 = 2_957_003
|
|
28
|
+
# The days from 1899-12-30 to 1970-01-01, the epoch of Time#to_i.
|
|
29
|
+
UNIX_EPOCH_DAYS = 25_569
|
|
30
|
+
# The Julian day of EPOCH_1900, so that the days of a Date are a subtraction.
|
|
31
|
+
EPOCH_JD = EPOCH_1900.jd
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# @param serial [Integer] the whole days of a serial, not negative
|
|
36
|
+
# @param date1904 [Boolean] whether the workbook counts from 1904-01-01
|
|
37
|
+
# @return [Date, nil] nil beyond the last serial
|
|
38
|
+
def date(serial, date1904:)
|
|
39
|
+
if date1904
|
|
40
|
+
EPOCH_1904 + serial if serial <= MAX_SERIAL_1904
|
|
41
|
+
elsif serial <= MAX_SERIAL
|
|
42
|
+
(serial < PHANTOM_DAY_SERIAL ? EPOCH_BEFORE_PHANTOM_DAY : EPOCH_1900) + serial
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param value [Date, Time] a date, or a UTC Time whose fields are the wall clock
|
|
47
|
+
# @return [Integer, nil] the serial of its day in the 1900 system; nil before 1900-01-01 or after 9999-12-31
|
|
48
|
+
def serial(value)
|
|
49
|
+
day_serial(value.is_a?(Time) ? days_and_seconds(value)[0] : value.jd - EPOCH_JD)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param days [Integer] days since 1899-12-30
|
|
53
|
+
# @return [Integer, nil] their serial in the 1900 system; nil before 1900-01-01 or after 9999-12-31
|
|
54
|
+
def day_serial(days)
|
|
55
|
+
days -= 1 if days < MARCH_1900_DAYS
|
|
56
|
+
days if days >= 1 && days <= MAX_SERIAL
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param time [Time] a UTC Time whose fields are the wall clock, in whole seconds
|
|
60
|
+
# @return [Array(Integer, Integer)] the days since 1899-12-30 and the seconds since midnight
|
|
61
|
+
def days_and_seconds(time)
|
|
62
|
+
(time.to_i + UNIX_EPOCH_DAYS * SECONDS_PER_DAY).divmod(SECONDS_PER_DAY)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|