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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "pathname"
5
+ require "stringio"
6
+ require "tempfile"
7
+
8
+ module Calcsheet
9
+ # Normalizes whatever the caller hands over (a path, an IO, a Tempfile) into a binary-mode IO, enforcing
10
+ # +max_file_size+. A seekable source is positioned at 0. A non-seekable stream is copied to a Tempfile when the
11
+ # backend needs random access (the copy is removed on close); otherwise it is read from where it is, after a rewind
12
+ # that only an IO which supports it does, through a CountingIO. An IO supplied by the caller is never closed or
13
+ # deleted by the gem.
14
+ class Source
15
+ # The bytes copied at a time from a stream to its Tempfile.
16
+ COPY_CHUNK_SIZE = 64 * 1024
17
+
18
+ # @return [IO] binary-mode; positioned at 0 when opened, unless it is a non-seekable stream read directly
19
+ attr_reader :io
20
+ # @return [String, nil] the path given by the caller or reported by the IO, or the original file name of an upload
21
+ attr_reader :path
22
+ # @return [Integer, nil] the size in bytes; +nil+ for a non-seekable stream
23
+ attr_reader :size
24
+
25
+ # Opens a source. With a block, yields it and closes it afterwards (like
26
+ # File.open); without one, returns it and the caller must call #close.
27
+ #
28
+ # @param source [String, Pathname, IO] a path, or anything that responds to +read+
29
+ # @param options [Options] supplies +max_file_size+
30
+ # @param needs_seek [Boolean] copy a non-seekable stream to a Tempfile
31
+ # @yieldparam source [Source]
32
+ # @return [Object, Source] the block's value, or the source without a block
33
+ # @raise [FileTooLargeError] when the input is larger than +max_file_size+ (a stream read directly raises it
34
+ # from its reads instead)
35
+ # @raise [ArgumentError] for a source that is neither a path nor responds to +read+
36
+ # @raise [SystemCallError] for a path that cannot be opened
37
+ def self.open(source, options, needs_seek:)
38
+ opened = new(source, options, needs_seek: needs_seek)
39
+ return opened unless block_given?
40
+
41
+ begin
42
+ yield opened
43
+ ensure
44
+ opened.close
45
+ end
46
+ end
47
+
48
+ # @api private
49
+ # See Source.open; the source is closed again when this raises (or is interrupted).
50
+ def initialize(source, options, needs_seek:)
51
+ @max_file_size = options.max_file_size
52
+ @owned = nil
53
+ case source
54
+ when String, Pathname then use_path(source)
55
+ else
56
+ raise ArgumentError, "expected a path or an IO, got #{source.class}" unless source.respond_to?(:read)
57
+
58
+ use_io(source, needs_seek)
59
+ end
60
+ rescue Exception # rubocop:disable Lint/RescueException
61
+ close
62
+ raise
63
+ end
64
+
65
+ # @return [Boolean] whether #io can seek: false only for a stream read directly
66
+ def seekable?
67
+ @seekable
68
+ end
69
+
70
+ # Closes what the source opened itself and deletes its own Tempfile.
71
+ # Idempotent; never touches an IO supplied by the caller.
72
+ def close
73
+ owned = @owned
74
+ @owned = nil
75
+ case owned
76
+ when Tempfile then owned.close!
77
+ when IO then owned.close unless owned.closed?
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def use_path(source)
84
+ @path = source.to_s
85
+ file = File.open(@path, "rb")
86
+ @owned = file
87
+ @io = file
88
+ @seekable = true
89
+ @size = file.size
90
+ check_size(@size)
91
+ end
92
+
93
+ def use_io(source, needs_seek)
94
+ @path = source.path if source.respond_to?(:path) && source.path.is_a?(String)
95
+ # The upload object of a web framework (Rails, Rack) is named as the user named it, not as the framework's
96
+ # Tempfile, whether it seeks itself (Rack's delegates seek to its Tempfile) or not.
97
+ @path = source.original_filename if source.respond_to?(:original_filename) && source.original_filename.is_a?(String)
98
+ # One that cannot seek is read through its file, in place, not copied.
99
+ upload = upload_file(source)
100
+ source = upload if upload
101
+ if source.is_a?(StringIO)
102
+ use_string_io(source)
103
+ elsif seekable_io?(source)
104
+ use_seekable(source)
105
+ else
106
+ use_stream(source, needs_seek)
107
+ end
108
+ end
109
+
110
+ # A private binary view of the caller's string: binmode on the StringIO
111
+ # itself would re-tag the caller's String, and reading would move its
112
+ # position. String#b shares the buffer until it is written.
113
+ def use_string_io(source)
114
+ @io = StringIO.new(source.string.b)
115
+ @seekable = true
116
+ @size = @io.size
117
+ check_size(@size)
118
+ end
119
+
120
+ def use_seekable(source)
121
+ source.binmode if source.respond_to?(:binmode)
122
+ @io = source
123
+ @seekable = true
124
+ @size = measure(source)
125
+ check_size(@size)
126
+ end
127
+
128
+ # A stream without seek: an upload object of a web framework, or a pipe.
129
+ def use_stream(source, needs_seek)
130
+ source.binmode if source.respond_to?(:binmode)
131
+ rewind(source)
132
+ @seekable = false
133
+ declared = source.size if source.respond_to?(:size)
134
+ if declared.is_a?(Integer)
135
+ @size = declared
136
+ check_size(declared)
137
+ end
138
+ if needs_seek
139
+ copy_to_tempfile(source)
140
+ else
141
+ @io = CountingIO.new(source, @max_file_size)
142
+ end
143
+ end
144
+
145
+ # @return [IO, nil] the IO behind an upload object that cannot seek itself but answers +to_io+
146
+ # (ActionDispatch::Http::UploadedFile); nil for an IO or a seekable object (a Tempfile, a
147
+ # Rack::Multipart::UploadedFile, which delegates seek to its Tempfile), read as it is
148
+ def upload_file(source)
149
+ return if source.is_a?(IO) || source.is_a?(StringIO) || !source.respond_to?(:to_io) || seekable_io?(source)
150
+
151
+ io = source.to_io
152
+ io if io.is_a?(IO) && !io.equal?(source)
153
+ end
154
+
155
+ # Reads start from the beginning, as for a seekable IO. A real stream
156
+ # cannot rewind and is simply read from where it is.
157
+ def rewind(stream)
158
+ stream.rewind if stream.respond_to?(:rewind)
159
+ rescue SystemCallError, IOError, NotImplementedError
160
+ nil
161
+ end
162
+
163
+ def seekable_io?(io)
164
+ return false unless io.respond_to?(:seek) && io.respond_to?(:pos)
165
+
166
+ io.seek(0, IO::SEEK_CUR)
167
+ true
168
+ rescue SystemCallError, IOError, NotImplementedError
169
+ false
170
+ end
171
+
172
+ # Total size from the start, whatever the current position; leaves the IO at 0.
173
+ def measure(io)
174
+ io.seek(0, IO::SEEK_END)
175
+ size = io.pos
176
+ io.seek(0, IO::SEEK_SET)
177
+ size
178
+ end
179
+
180
+ def copy_to_tempfile(stream)
181
+ copy = Tempfile.new("calcsheet")
182
+ @owned = copy
183
+ copy.binmode
184
+ total = 0
185
+ while (chunk = stream.read(COPY_CHUNK_SIZE))
186
+ # A duck-typed read(length) may answer "" instead of nil at the end.
187
+ break if chunk.empty?
188
+
189
+ total += chunk.bytesize
190
+ check_size(total)
191
+ copy.write(chunk)
192
+ end
193
+ copy.flush
194
+ copy.rewind
195
+ @io = copy
196
+ @seekable = true
197
+ @size = total
198
+ end
199
+
200
+ def check_size(actual)
201
+ return if @max_file_size.nil? || actual <= @max_file_size
202
+
203
+ raise FileTooLargeError.new(limit: :max_file_size, max: @max_file_size, actual: actual)
204
+ end
205
+
206
+ # Wraps a stream read directly (no Tempfile) and raises FileTooLargeError as
207
+ # soon as the bytes read pass +max_file_size+. The CSV reader reads it with +read+ only (its detection sample,
208
+ # then CsvInput); the other reading methods are counted as well, so that no read passes the limit unseen.
209
+ #
210
+ # @api private
211
+ class CountingIO < SimpleDelegator
212
+ def initialize(io, max)
213
+ super(io)
214
+ @max = max
215
+ @total = 0
216
+ end
217
+
218
+ def read(*args)
219
+ count(__getobj__.read(*args))
220
+ end
221
+
222
+ def readpartial(*args)
223
+ count(__getobj__.readpartial(*args))
224
+ end
225
+
226
+ def gets(*args)
227
+ count(__getobj__.gets(*args))
228
+ end
229
+
230
+ def readline(*args)
231
+ count(__getobj__.readline(*args))
232
+ end
233
+
234
+ def getc
235
+ count(__getobj__.getc)
236
+ end
237
+
238
+ def each_line(*args)
239
+ return to_enum(:each_line, *args) unless block_given?
240
+
241
+ while (line = gets(*args))
242
+ yield line
243
+ end
244
+ self
245
+ end
246
+
247
+ def readlines(*args)
248
+ each_line(*args).to_a
249
+ end
250
+
251
+ private
252
+
253
+ def count(data)
254
+ return data if data.nil? || @max.nil?
255
+
256
+ @total += data.bytesize
257
+ return data if @total <= @max
258
+
259
+ raise FileTooLargeError.new(limit: :max_file_size, max: @max, actual: @total)
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "write_book"
4
+
5
+ module Calcsheet
6
+ # A Rack body returned by Calcsheet.stream: #each runs the writing block synchronously, in the caller's
7
+ # thread, and yields the file in binary chunks of at least CHUNK_SIZE bytes (the last one may be shorter) while
8
+ # the block is still producing rows. No thread or fiber is involved, so memory stays at about one chunk.
9
+ #
10
+ # Every call of #each runs the block again and yields the whole file again. An exception of the block propagates
11
+ # from #each after the chunks already yielded, so the receiver gets a truncated file.
12
+ class StreamBody
13
+ # The smallest chunk #each yields, but the last one.
14
+ CHUNK_SIZE = 64 * 1024
15
+
16
+ # Buffers what the writer backend writes and hands it on in chunks.
17
+ #
18
+ # @api private
19
+ class Chunker
20
+ # @yieldparam chunk [String] a binary chunk of at least CHUNK_SIZE bytes, the last one excepted
21
+ def initialize(&emit)
22
+ @emit = emit
23
+ @buffer = new_buffer
24
+ end
25
+
26
+ # @return [Integer] the bytes written
27
+ def write(string)
28
+ @buffer << (string.encoding == Encoding::BINARY || string.ascii_only? ? string : string.b)
29
+ flush if @buffer.bytesize >= CHUNK_SIZE
30
+ string.bytesize
31
+ end
32
+
33
+ # @return [self]
34
+ def <<(string)
35
+ write(string)
36
+ self
37
+ end
38
+
39
+ # Yields the bytes still buffered, if any.
40
+ def close
41
+ flush unless @buffer.empty?
42
+ end
43
+
44
+ private
45
+
46
+ def flush
47
+ chunk = @buffer
48
+ @buffer = new_buffer
49
+ @emit.call(chunk)
50
+ end
51
+
52
+ def new_buffer
53
+ String.new(capacity: CHUNK_SIZE * 2, encoding: Encoding::BINARY)
54
+ end
55
+ end
56
+
57
+ # @api private
58
+ # @param writer_class [Class] a writer backend
59
+ # @param options [Options] as returned by WriteBook.prepare_options
60
+ # @param block [Proc] the writing block, called with the WriteBook
61
+ def initialize(writer_class, options, block)
62
+ @writer_class = writer_class
63
+ @options = options
64
+ @block = block
65
+ end
66
+
67
+ # Runs the writing block and yields the file.
68
+ # @yieldparam chunk [String] binary, a new String per chunk
69
+ # @return [self, Enumerator] an Enumerator without a block
70
+ # @raise [WriteError] what the writing block raises, after the chunks already yielded
71
+ # @raise [ArgumentError] what the writing block raises (an invalid sheet, column or row)
72
+ def each(&emit)
73
+ return enum_for(:each) unless emit
74
+
75
+ chunker = Chunker.new(&emit)
76
+ WriteBook.write(chunker, @writer_class, @options, &@block)
77
+ chunker.close
78
+ self
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # Resolves the +time_zone:+ option into an object that builds times from wall-clock
5
+ # fields and reads them back. Spreadsheet datetimes carry no zone, so the zone only
6
+ # decides which instant the fields stand for.
7
+ #
8
+ # @api private
9
+ module TimeZone
10
+ # A UTC offset as the option gives it: "+01:00" or "+0100".
11
+ OFFSET = /\A([+-])(\d\d):?(\d\d)\z/
12
+ # The Strings that mean UTC.
13
+ UTC_NAMES = %w[UTC Z].freeze
14
+
15
+ # UTC, the default.
16
+ class Utc
17
+ # @return [Time] a UTC Time of the fields
18
+ def local(year, month, day, hour, min, sec) = Time.utc(year, month, day, hour, min, sec)
19
+
20
+ # @return [Array<Integer>] the UTC fields of +time+: year, month, day, hour, minute, second
21
+ def wall_clock(time)
22
+ time = time.getutc
23
+ [ time.year, time.month, time.day, time.hour, time.min, time.sec ]
24
+ end
25
+ end
26
+
27
+ # A fixed UTC offset such as "+01:00".
28
+ class FixedOffset
29
+ # @param offset [String] as OFFSET matches it
30
+ # @raise [ArgumentError] for an offset Time refuses ("+25:00")
31
+ def initialize(offset)
32
+ @offset = offset
33
+ Time.new(2000, 1, 1, 0, 0, 0, offset)
34
+ rescue ArgumentError
35
+ raise ArgumentError, "invalid time zone offset: #{offset.inspect}"
36
+ end
37
+
38
+ # @return [Time] a Time of the fields at the offset
39
+ def local(year, month, day, hour, min, sec) = Time.new(year, month, day, hour, min, sec, @offset)
40
+
41
+ # @return [Array<Integer>] the fields of +time+ at the offset: year, month, day, hour, minute, second
42
+ def wall_clock(time)
43
+ time = time.getlocal(@offset)
44
+ [ time.year, time.month, time.day, time.hour, time.min, time.sec ]
45
+ end
46
+ end
47
+
48
+ # The zone of +time_zone: nil+.
49
+ UTC = Utc.new.freeze
50
+
51
+ class << self
52
+ # @param option [nil, String, #local] +nil+ or "UTC"/"Z" for UTC, a UTC offset such as "+01:00",
53
+ # or any object responding to +local(year, month, day, hour, min, sec)+, returned unchanged
54
+ # @return [#local]
55
+ # @raise [ArgumentError] when the option is none of these
56
+ def wrap(option)
57
+ case option
58
+ when nil then UTC
59
+ when String then string_zone(option)
60
+ else
61
+ return option if option.respond_to?(:local)
62
+
63
+ raise ArgumentError, "invalid time_zone: #{option.inspect} (expected nil, a UTC offset or an object responding to local)"
64
+ end
65
+ end
66
+
67
+ # @return [Boolean] whether #wall_clock can convert an instant into +zone+
68
+ def converts?(zone)
69
+ zone.respond_to?(:wall_clock) || zone.respond_to?(:utc_to_local) || zone.respond_to?(:utc_offset)
70
+ end
71
+
72
+ # The wall-clock fields of +time+ in +zone+, as [year, month, day, hour, min, sec].
73
+ # Zones from #wrap answer directly; a duck-typed zone needs +utc_to_local+ or +utc_offset+.
74
+ # @raise [ArgumentError] when the zone can do neither
75
+ def wall_clock(zone, time)
76
+ return zone.wall_clock(time) if zone.respond_to?(:wall_clock)
77
+
78
+ local =
79
+ if zone.respond_to?(:utc_to_local) then zone.utc_to_local(time.getutc)
80
+ elsif zone.respond_to?(:utc_offset) then time.getlocal(zone.utc_offset)
81
+ else raise ArgumentError, "time_zone #{zone.inspect} must respond to utc_to_local or utc_offset to write times"
82
+ end
83
+ [ local.year, local.month, local.day, local.hour, local.min, local.sec ]
84
+ end
85
+
86
+ private
87
+
88
+ def string_zone(option)
89
+ return UTC if UTC_NAMES.include?(option)
90
+ raise ArgumentError, "invalid time_zone: #{option.inspect} (expected a UTC offset such as \"+01:00\")" unless OFFSET.match?(option)
91
+
92
+ FixedOffset.new(option)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # Untrusted text as error messages show it: what a file says (zip entry names, attribute values, sheet names, cell
5
+ # values) is cut to MAX_CHARACTERS, so that a crafted file cannot make a message as large as its own content.
6
+ #
7
+ # @api private
8
+ module Untrusted
9
+ MAX_CHARACTERS = 200
10
+
11
+ module_function
12
+
13
+ # @param value [Object] a String or any other value of a file
14
+ # @return [String] the value inspected, so that control characters and invalid bytes are escaped, with "..." after
15
+ # it when it was cut
16
+ def show(value)
17
+ unless value.is_a?(String)
18
+ shown = value.inspect
19
+ return shown.length > MAX_CHARACTERS ? "#{shown[0, MAX_CHARACTERS]}..." : shown
20
+ end
21
+
22
+ value.length > MAX_CHARACTERS ? "#{value[0, MAX_CHARACTERS].inspect}..." : value.inspect
23
+ end
24
+
25
+ # @param text [String, nil] a name shown between the quotes of a location, such as +sheet '...'+
26
+ # @return [String] the text as it is, with "..." after it when it was cut
27
+ def cut(text)
28
+ text = text.to_s
29
+ text.length > MAX_CHARACTERS ? "#{text[0, MAX_CHARACTERS]}..." : text
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # The version of the gem.
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "sheet_names"
5
+ require_relative "time_zone"
6
+ require_relative "write_sheet"
7
+
8
+ module Calcsheet
9
+ # A workbook being written, yielded by Calcsheet.write and Calcsheet.stream. Sheets are written one after
10
+ # another; a closed sheet cannot be reopened.
11
+ #
12
+ # == Writer backend contract
13
+ #
14
+ # A writer backend is a class registered in Calcsheet.writers under its format. The facade validates every
15
+ # value before the backend sees it, applies the sheet names and the overflow, and calls, in this order:
16
+ #
17
+ # - +.max_rows_per_sheet+ -> Integer or nil (class method): rows a sheet holds, header included; nil for no
18
+ # limit (CSV). The facade continues in a new sheet at this limit, or at the lower +max_rows_per_sheet:+ option;
19
+ # an explicit option above it raises ArgumentError before anything is written, the default is lowered to it.
20
+ # - +.prepare_options(options, explicit:)+ (optional class method): raises ArgumentError on an invalid format
21
+ # option and returns the Options to write with, presets resolved; +explicit+ lists the option keys given per
22
+ # call, which win over presets. It runs before anything is written, also when Calcsheet.stream builds its body.
23
+ # - +.new(io, options)+: +io+ responds to +write(string)+ and +<<(string)+ and may be non-seekable; +options+ are
24
+ # those prepared, frozen. The backend must not close +io+.
25
+ # - +#start_sheet(name, columns, header:)+, for each sheet and each continuation: +name+ is valid and unique,
26
+ # +columns+ an Array of frozen Column, and with +header: true+ the backend writes the header row (the column
27
+ # headers) itself.
28
+ # - +#row(values)+: one value per column, in column order: nil (empty cell), or a value of the column type —
29
+ # String in UTF-8 (:string), Integer (:integer), Integer or finite BigDecimal (:decimal, written unrounded;
30
+ # +scale+ is display only), Date (:date), true/false (:boolean), and for :datetime a UTC Time in whole seconds
31
+ # whose fields are the wall clock to write (the +time_zone:+ conversion is already applied). A value the format
32
+ # cannot hold (a date beyond its range, a text too long) raises WriteError, and nothing of the row may stay
33
+ # written: the sheet goes on with the next row.
34
+ # - +#text_bytesize(text, column_index)+ (optional): the bytes the backend writes for a text of that column, when
35
+ # it adds to it (the CSV formula prefix); the facade asks it only for a text of +max_cell_bytes+ bytes or more,
36
+ # and refuses one the readers would refuse with a WriteError. Without it, the bytes of the text count.
37
+ # - +#header_bytesize(column)+ (optional): the bytes the backend writes for the header of that column, likewise
38
+ # (the CSV formula prefix applies to the headers of every column type); the facade asks it, with +header: true+
39
+ # and +max_cell_bytes+, before #start_sheet, and refuses a header the readers would refuse with a WriteError.
40
+ # - +#row_problem(values)+ (optional): the row #row would refuse with a WriteError, as +[column_index, reason]+, or
41
+ # nil; asked, without writing, for the row that overflows into a continuation sheet, so that a refused row
42
+ # opens none.
43
+ # - +#end_sheet+ after the last row of each sheet.
44
+ # - +#finish+ once, after the last sheet, also when the book has none: writes what completes the file.
45
+ #
46
+ # When the caller's block raises, the facade stops calling the backend and the output is left incomplete.
47
+ class WriteBook
48
+ # Gives a target that responds to only one of +write+ and +<<+ both, as the backend contract promises.
49
+ #
50
+ # @api private
51
+ class Output
52
+ # @param target [#write, #<<]
53
+ def initialize(target)
54
+ @target = target
55
+ @write = target.respond_to?(:write)
56
+ end
57
+
58
+ # @return [Integer] the bytes written
59
+ def write(string)
60
+ @write ? @target.write(string) : @target << string
61
+ string.bytesize
62
+ end
63
+
64
+ # @return [self]
65
+ def <<(string)
66
+ write(string)
67
+ self
68
+ end
69
+ end
70
+
71
+ class << self
72
+ # @api private
73
+ # Checks the writing options shared by every format and the backend's, before anything is written, and
74
+ # resolves the row limit and the backend's presets.
75
+ # @param explicit [Array<Symbol>] the option keys given per call
76
+ # @return [Options] the options to write with
77
+ # @raise [ArgumentError] an invalid time zone, a +max_rows_per_sheet+ above the format's limit, or a format
78
+ # option the backend refuses
79
+ def prepare_options(writer_class, format, options, explicit:)
80
+ zone(options)
81
+ options = options.with(max_rows_per_sheet: row_limit(writer_class, format, options, explicit))
82
+ writer_class.respond_to?(:prepare_options) ? writer_class.prepare_options(options, explicit: explicit) : options
83
+ end
84
+
85
+ # @api private
86
+ # @param target [Object] responds to +write+ or +<<+
87
+ # @return [Object, nil] an IO for the backend; nil when +target+ responds to neither
88
+ def output(target)
89
+ return target if target.respond_to?(:write) && target.respond_to?(:<<)
90
+
91
+ Output.new(target) if target.respond_to?(:write) || target.respond_to?(:<<)
92
+ end
93
+
94
+ # @api private
95
+ # Writes a workbook to +io+ with the options returned by prepare_options.
96
+ # @param io [Object] responds to +write+ and +<<+
97
+ # @yieldparam book [WriteBook]
98
+ # @return [nil]
99
+ def write(io, writer_class, options)
100
+ book = new(writer_class.new(io, options), options: options)
101
+ yield book
102
+ book.finish
103
+ nil
104
+ end
105
+
106
+ # @api private
107
+ # @return [#local, nil] the time_zone that converts written times, nil to keep their own wall clock
108
+ # @raise [ArgumentError] the zone is invalid, or cannot give the wall clock of a Time (TimeZone.wall_clock)
109
+ def zone(options)
110
+ return nil if options.time_zone.nil?
111
+
112
+ zone = TimeZone.wrap(options.time_zone)
113
+ return zone if %i[wall_clock utc_to_local utc_offset].any? { zone.respond_to?(_1) }
114
+
115
+ raise ArgumentError, "time_zone #{zone.inspect} must respond to utc_to_local or utc_offset to write times"
116
+ end
117
+
118
+ private
119
+
120
+ # The rows per sheet: nil without a backend limit (the option is ignored), else the option, the default lowered
121
+ # to the backend's limit.
122
+ def row_limit(writer_class, format, options, explicit)
123
+ option = options.max_rows_per_sheet
124
+ backend = writer_class.max_rows_per_sheet
125
+ return nil if backend.nil?
126
+ return backend if option.nil?
127
+ return option if option <= backend
128
+ return backend if !explicit.include?(:max_rows_per_sheet) && option == Configuration::DEFAULTS[:max_rows_per_sheet]
129
+
130
+ raise ArgumentError, "max_rows_per_sheet: #{option} exceeds the limit of the #{format} format (#{backend})"
131
+ end
132
+ end
133
+
134
+ # @api private
135
+ # @param writer [Object] a writer backend
136
+ # @param options [Options] as returned by prepare_options
137
+ def initialize(writer, options:)
138
+ @writer = writer
139
+ @zone = self.class.zone(options)
140
+ @limit = options.max_rows_per_sheet
141
+ @max_cell_bytes = options.max_cell_bytes
142
+ @names = SheetNames.new(sanitize: options.sanitize_sheet_names)
143
+ @sheet = nil
144
+ @failed = nil
145
+ @finished = false
146
+ end
147
+
148
+ # Writes one sheet: yields it, then ends it. When the block raises (or leaves with break or throw), the sheet is
149
+ # left incomplete and the book refuses any further sheet or #finish with a WriteError.
150
+ #
151
+ # @param name [String] valid and unique (see SheetNames), unless +sanitize_sheet_names: true+ repairs it
152
+ # @param columns [Array<Hash, Column>] see Column.from
153
+ # @param header [Boolean] write the column headers as the first row
154
+ # @yieldparam sheet [WriteSheet]
155
+ # @return [nil]
156
+ # @raise [ArgumentError] a missing block, invalid columns or name, too many columns for the format, a sheet
157
+ # opened inside another one's block, or a book already finished
158
+ # @raise [WriteError] an earlier sheet block raised; a second sheet in CSV; a header longer than an XLSX cell, or
159
+ # (written) than +max_cell_bytes+; the sheet is not started
160
+ def sheet(name, columns:, header: true)
161
+ raise ArgumentError, "a block is required" unless block_given?
162
+
163
+ check_usable
164
+ raise ArgumentError, "sheet #{Untrusted.show(@sheet.name)} is still open" if @sheet
165
+
166
+ sheet = @sheet = WriteSheet.new(@writer, @names, name, columns, header: header, limit: @limit, zone: @zone,
167
+ max_cell_bytes: @max_cell_bytes)
168
+ completed = false
169
+ begin
170
+ yield sheet
171
+ completed = true
172
+ ensure
173
+ # An exception, break or throw leaves the sheet incomplete: the book refuses to go on (#check_usable).
174
+ @sheet = nil
175
+ unless completed
176
+ @failed = sheet.name
177
+ sheet.send(:abandon)
178
+ end
179
+ end
180
+ # Private in WriteSheet, so that the block cannot end the sheet itself.
181
+ sheet.send(:close)
182
+ nil
183
+ end
184
+
185
+ # @api private
186
+ # Completes the file. Calcsheet.write and the StreamBody call it after the block.
187
+ # @raise [WriteError] a sheet block raised, so the file would be incomplete
188
+ # @raise [ArgumentError] the book is finished already
189
+ def finish
190
+ check_usable
191
+ @finished = true
192
+ @writer.finish
193
+ end
194
+
195
+ private
196
+
197
+ def check_usable
198
+ raise ArgumentError, "the book is finished" if @finished
199
+ raise WriteError.new("sheet '#{@failed}' did not complete: the file would be incomplete", sheet: @failed) if @failed
200
+ end
201
+ end
202
+ end