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,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