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