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,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require_relative "../cast"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../formula_protection"
|
|
8
|
+
require_relative "../raw_row"
|
|
9
|
+
require_relative "../reading"
|
|
10
|
+
require_relative "../sheet_info"
|
|
11
|
+
require_relative "csv_dialect"
|
|
12
|
+
require_relative "csv_input"
|
|
13
|
+
require_relative "row_batches"
|
|
14
|
+
|
|
15
|
+
module Calcsheet
|
|
16
|
+
# The reader backends registered in Calcsheet.backends (CsvReader, XlsxReader, OdsReader), which implement the
|
|
17
|
+
# contract of Reading, and what they share: RowBatches, the SAX handling of SaxSheet and OxErrors, and the byte
|
|
18
|
+
# scanners of TagScan and StreamRemainder. Xlsx and Ods hold the parts of each format.
|
|
19
|
+
#
|
|
20
|
+
# @api private
|
|
21
|
+
module Readers
|
|
22
|
+
# The CSV reader backend (see Reading for the contract): one sheet, named after the file without its extension
|
|
23
|
+
# ("csv" for an IO without a path), parsed by the stdlib CSV in the detected dialect.
|
|
24
|
+
#
|
|
25
|
+
# Rows are numbered by CSV record, counting the header and blank lines, and +line+ is the physical line where
|
|
26
|
+
# the record starts. A seekable source is read again from its start whenever a request needs a record
|
|
27
|
+
# already parsed or another dialect; a non-seekable stream is read directly, forward only, so it can be read
|
|
28
|
+
# once. The options +encoding+, +col_sep+ and +quote_char+ of the read force the dialect; nil detects it.
|
|
29
|
+
#
|
|
30
|
+
# @api private
|
|
31
|
+
class CsvReader
|
|
32
|
+
include RowBatches
|
|
33
|
+
|
|
34
|
+
# The message of a second read of a stream read directly.
|
|
35
|
+
READ_TWICE = "a non-seekable CSV stream cannot be read twice; pass a file or seekable IO"
|
|
36
|
+
# The line break that does not belong to each line ending: CSV keeps it in an unquoted field. A stray one in a
|
|
37
|
+
# CRLF file CSV refuses itself, with the message UNQUOTED_BREAK starts.
|
|
38
|
+
STRAY_BREAKS = { "\n" => "\r", "\r" => "\n" }.freeze
|
|
39
|
+
UNQUOTED_BREAK = "Unquoted fields do not allow new line"
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] false: a stream is read directly (see Source)
|
|
42
|
+
def self.needs_seek?
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param source [Source]
|
|
47
|
+
# @param options [Options] the book options (+max_cell_bytes+); the read options come with each request's Cast
|
|
48
|
+
def initialize(source, options)
|
|
49
|
+
@source = source
|
|
50
|
+
@max_cell_bytes = options.max_cell_bytes
|
|
51
|
+
@name = sheet_name(source.path)
|
|
52
|
+
# The detection sample cannot be given back to a stream: it is replayed before the rest of it, once (#input
|
|
53
|
+
# consumes it; a stream is read once, see #restart).
|
|
54
|
+
@sample = source.io.read(CsvDialect::SAMPLE_BYTES).to_s.b unless source.seekable?
|
|
55
|
+
# The dialect options of the last restart, and the dialect they gave.
|
|
56
|
+
@forced = nil
|
|
57
|
+
@dialect = nil
|
|
58
|
+
# The parser and the Enumerator over its records (see #shift); nil when the next request starts again.
|
|
59
|
+
@csv = nil
|
|
60
|
+
@rows = nil
|
|
61
|
+
# Whether a stream has been read, which it can be only once.
|
|
62
|
+
@started = false
|
|
63
|
+
# The records parsed, and the physical line where the next one starts.
|
|
64
|
+
@records = 0
|
|
65
|
+
@line = 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The file name without its directories and extension; an upload's name may hold Windows separators.
|
|
69
|
+
# @return [String] "csv" without a name
|
|
70
|
+
def sheet_name(path)
|
|
71
|
+
name = path && File.basename(path.split(/[\\\/]/).last.to_s, ".*")
|
|
72
|
+
name.nil? || name.empty? ? "csv" : name
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Array<SheetInfo>]
|
|
76
|
+
def sheets
|
|
77
|
+
[ SheetInfo.new(name: @name, index: 0) ]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Drops the parser and the sample; the book closes the source.
|
|
81
|
+
def close
|
|
82
|
+
@csv = nil
|
|
83
|
+
@rows = nil
|
|
84
|
+
@sample = nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Yields the non-blank rows numbered +start+ or later (see RowBatches; a CSV has one sheet). The counters are
|
|
90
|
+
# updated before each yield, so that a caller breaking out leaves the parser on the next unread record.
|
|
91
|
+
def each_row(_sheet_index, cast, types, start)
|
|
92
|
+
forced = forced_dialect(cast.options)
|
|
93
|
+
# The parser goes on from where the last request stopped only when that is before +start+ and the dialect is
|
|
94
|
+
# the same: a request for a record already parsed (the header after a sample, a second read), another
|
|
95
|
+
# forced dialect or a parser that failed starts again from the first record.
|
|
96
|
+
restart(forced) if @csv.nil? || start <= @records || forced != @forced
|
|
97
|
+
strip = cast.options.strip_formula_prefix
|
|
98
|
+
while (fields = shift)
|
|
99
|
+
number = @records += 1
|
|
100
|
+
line = @line
|
|
101
|
+
raw = @csv.line
|
|
102
|
+
@line += line_breaks(raw)
|
|
103
|
+
@stray = STRAY_BREAKS.fetch(@row_sep = @csv.row_sep, false) if @stray.nil?
|
|
104
|
+
mixed_line_endings(number, line, @row_sep) if @stray && raw.include?(@stray) && stray_break?(raw)
|
|
105
|
+
next if number < start
|
|
106
|
+
|
|
107
|
+
last = fields.size - 1
|
|
108
|
+
last -= 1 while last >= 0 && blank_field?(fields[last], number, last)
|
|
109
|
+
next if last.negative?
|
|
110
|
+
|
|
111
|
+
fields = fields.first(last + 1) if last + 1 < fields.size
|
|
112
|
+
yield raw_row(number, line, fields, cast, types, strip)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Parses from the first record again: a seekable source from its start, a stream from its replayed sample, which
|
|
117
|
+
# is possible once. The dialect is detected again only when the forced options changed.
|
|
118
|
+
# @raise [ArgumentError] READ_TWICE for a stream already read
|
|
119
|
+
def restart(forced)
|
|
120
|
+
raise ArgumentError, READ_TWICE if @started && !@source.seekable?
|
|
121
|
+
|
|
122
|
+
unless @dialect && forced == @forced
|
|
123
|
+
io = @source.seekable? ? @source.io : StringIO.new(@sample)
|
|
124
|
+
@dialect = CsvDialect.detect(io, seekable: @source.seekable?, **forced)
|
|
125
|
+
@forced = forced
|
|
126
|
+
end
|
|
127
|
+
@started = true
|
|
128
|
+
@csv = CSV.new(input, col_sep: @dialect.col_sep, quote_char: @dialect.quote_char, row_sep: @dialect.row_sep)
|
|
129
|
+
@stray = nil
|
|
130
|
+
@rows = nil
|
|
131
|
+
@records = 0
|
|
132
|
+
@line = 1
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The text the parser reads: the source after the byte order mark, or the sample (consumed here) and the rest of
|
|
136
|
+
# the stream.
|
|
137
|
+
def input
|
|
138
|
+
bom = @dialect.bom_size
|
|
139
|
+
return CsvInput.new(@source.io.tap { _1.seek(bom, IO::SEEK_SET) }, @dialect.encoding) if @source.seekable?
|
|
140
|
+
|
|
141
|
+
prefix = @sample.byteslice(bom, @sample.bytesize - bom)
|
|
142
|
+
@sample = nil
|
|
143
|
+
CsvInput.new(@source.io, @dialect.encoding, prefix: prefix)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The next record, nil at the end. Records are pulled from an Enumerator over CSV#each, not CSV#shift: in csv
|
|
147
|
+
# 3.3.x each shift abandons a parse and leaks one scanner position, so memory grows with the records read. The
|
|
148
|
+
# parser is created, and reads its first bytes, by the first call, inside the error handling below.
|
|
149
|
+
def shift
|
|
150
|
+
(@rows ||= @csv.each).next
|
|
151
|
+
rescue StopIteration
|
|
152
|
+
nil
|
|
153
|
+
rescue CSV::MalformedCSVError, EncodingError => error
|
|
154
|
+
row_sep = @csv.row_sep
|
|
155
|
+
@csv = @rows = nil
|
|
156
|
+
record = @records + 1
|
|
157
|
+
mixed_line_endings(record, @line, row_sep) if error.message.start_with?(UNQUOTED_BREAK)
|
|
158
|
+
raise MalformedFileError.new("CSV record #{record} (line #{@line}): #{error.message}", sheet: @name, row: record)
|
|
159
|
+
rescue StandardError
|
|
160
|
+
# A parser that failed (a size limit, an IO error) cannot continue: the next request starts again.
|
|
161
|
+
@csv = @rows = nil
|
|
162
|
+
raise
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Physical lines a record ends: "\r\n", "\n" and "\r" each end one, inside quoted fields too.
|
|
166
|
+
def line_breaks(raw)
|
|
167
|
+
breaks = raw.count("\n")
|
|
168
|
+
position = 0
|
|
169
|
+
while (cr = raw.byteindex("\r", position))
|
|
170
|
+
breaks += 1 unless raw.getbyte(cr + 1) == 0x0A
|
|
171
|
+
position = cr + 1
|
|
172
|
+
end
|
|
173
|
+
breaks
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Whether the stray line break of a record is outside its quoted fields: the parts between quote characters
|
|
177
|
+
# alternate outside and inside, a doubled quote giving an empty inside part.
|
|
178
|
+
def stray_break?(raw)
|
|
179
|
+
raw.split(@dialect.quote_char, -1).each_slice(2).any? { |outside, _inside| outside.include?(@stray) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Records end with the first line ending of the file; another one outside quoted fields would leave a CR in
|
|
183
|
+
# a value or merge two records.
|
|
184
|
+
def mixed_line_endings(record, line, row_sep)
|
|
185
|
+
@csv = @rows = nil
|
|
186
|
+
raise MalformedFileError.new("CSV record #{record} (line #{line}): mixed line endings: the file's lines end " \
|
|
187
|
+
"with #{row_sep.inspect}, this record has another line ending outside " \
|
|
188
|
+
"quoted fields", sheet: @name, row: record)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The dialect options of a read (checked by OptionValidation, except whether the encoding can be read): nil
|
|
192
|
+
# detects.
|
|
193
|
+
def forced_dialect(options)
|
|
194
|
+
{ encoding: options.encoding && readable_encoding(options.encoding), col_sep: options.col_sep,
|
|
195
|
+
quote_char: options.quote_char }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The encoding named by +value+, when it can be transcoded to UTF-8.
|
|
199
|
+
def readable_encoding(value)
|
|
200
|
+
encoding = Encoding.find(value)
|
|
201
|
+
Encoding::Converter.new(encoding, Encoding::UTF_8) unless encoding == Encoding::UTF_8
|
|
202
|
+
encoding
|
|
203
|
+
rescue ArgumentError, Encoding::ConverterNotFoundError => error
|
|
204
|
+
raise ArgumentError, "encoding: cannot read #{value.inspect} (#{error.message})"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def raw_row(number, line, fields, cast, types, strip)
|
|
208
|
+
errors = nil
|
|
209
|
+
# Array.new with a block rather than each_with_index.map, which allocates an Enumerator per row.
|
|
210
|
+
values = Array.new(fields.size) do |index|
|
|
211
|
+
field = fields[index]
|
|
212
|
+
next if field.nil? || field.empty?
|
|
213
|
+
|
|
214
|
+
check_size(field, number, index) if @max_cell_bytes && field.bytesize > @max_cell_bytes
|
|
215
|
+
field = FormulaProtection.unprotect(field) if strip
|
|
216
|
+
cast.text(field, types&.[](index))
|
|
217
|
+
rescue Cast::Failure => failure
|
|
218
|
+
(errors ||= []) << [ index, field, failure.reason ]
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
RawRow.new(number: number, line: line, values: values, errors: errors)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Every field is measured before a regexp sees it: here those from the end to the last non-blank one, the
|
|
225
|
+
# others in raw_row.
|
|
226
|
+
def blank_field?(field, number, index)
|
|
227
|
+
return true if field.nil?
|
|
228
|
+
|
|
229
|
+
check_size(field, number, index) if @max_cell_bytes && field.bytesize > @max_cell_bytes
|
|
230
|
+
!Reading::NON_BLANK.match?(field)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def check_size(field, number, index)
|
|
234
|
+
raise MalformedFileError.at("the field is longer than max_cell_bytes (#{@max_cell_bytes} bytes)",
|
|
235
|
+
sheet: @name, row: number, column_index: index)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Calcsheet
|
|
4
|
+
module Readers
|
|
5
|
+
module Ods
|
|
6
|
+
# What the styles of an ODS document say about how a value reads, gathered by Scanner from the data
|
|
7
|
+
# styles and the cell styles of styles.xml and of the automatic styles of content.xml: a date style that shows a
|
|
8
|
+
# time or none, and a plain zero-padding number style (+00000+: one number:number without decimals or grouping,
|
|
9
|
+
# nothing else). The Scanner feeds the elements in document order; #to_h resolves each cell style through its
|
|
10
|
+
# parents.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class CellFormats
|
|
14
|
+
# Parents followed from a cell style to the one that names its data style; a longer chain is not followed.
|
|
15
|
+
MAX_PARENTS = 8
|
|
16
|
+
|
|
17
|
+
# @return [Symbol, nil] :date or :number while inside a data style that matters, else nil
|
|
18
|
+
attr_reader :current
|
|
19
|
+
# @return [Integer, nil] the digits of the zero-padding number of the current number style
|
|
20
|
+
attr_reader :digits
|
|
21
|
+
|
|
22
|
+
# +@cells+ holds +[data style, parent]+ by cell style name, +@formats+ the +[kind, pad]+ by data style name.
|
|
23
|
+
def initialize
|
|
24
|
+
@cells = {}
|
|
25
|
+
@formats = {}
|
|
26
|
+
@current = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param name [String] a cell style
|
|
30
|
+
# @param data [String, nil] its data style
|
|
31
|
+
# @param parent [String, nil] its parent style
|
|
32
|
+
def cell_style(name, data, parent)
|
|
33
|
+
@cells[name] = [ data, parent ].freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Starts a data style of +kind+ (:date or :number) named +name+.
|
|
37
|
+
def start(kind, name)
|
|
38
|
+
@current = kind
|
|
39
|
+
@name = name
|
|
40
|
+
@time = false
|
|
41
|
+
@digits = nil
|
|
42
|
+
@plain = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A time field of the current date style.
|
|
46
|
+
def time!
|
|
47
|
+
@time = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether the current number style may still be a plain zero-padding format.
|
|
51
|
+
def plain?
|
|
52
|
+
@plain
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The first number:number of the current number style: the digits of a zero-padding format, or nil.
|
|
56
|
+
def number!(digits)
|
|
57
|
+
@digits = digits
|
|
58
|
+
@plain = false if digits.nil?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Another element in the current number style: it is not a plain zero-padding format.
|
|
62
|
+
def not_plain!
|
|
63
|
+
@plain = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Keeps the format of the data style that ends, when it has one (a date style; a number style that stayed a
|
|
67
|
+
# plain zero padding), and leaves it.
|
|
68
|
+
def finish
|
|
69
|
+
if @name && @current == :date
|
|
70
|
+
@formats[@name] = [ @time ? :datetime : :date, nil ].freeze
|
|
71
|
+
elsif @name && @current == :number && @plain && @digits
|
|
72
|
+
@formats[@name] = [ :general, @digits ].freeze
|
|
73
|
+
end
|
|
74
|
+
@current = nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param other [CellFormats] styles that win over these (content.xml over styles.xml)
|
|
78
|
+
# @return [CellFormats]
|
|
79
|
+
def merge(other)
|
|
80
|
+
merged = CellFormats.new
|
|
81
|
+
merged.absorb(@cells.merge(other.cells), @formats.merge(other.formats))
|
|
82
|
+
merged
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Hash{String => Array(Symbol, Integer | nil)}] +[kind, pad]+ (see Scanner#cell_formats) of each cell
|
|
86
|
+
# style whose data style, its own or its nearest parent's, has one
|
|
87
|
+
def to_h
|
|
88
|
+
@cells.each_key.filter_map { |name| (format = resolve(name)) && [ name, format ] }.to_h.freeze
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
protected
|
|
92
|
+
|
|
93
|
+
attr_reader :cells, :formats
|
|
94
|
+
|
|
95
|
+
def absorb(cells, formats)
|
|
96
|
+
@cells = cells
|
|
97
|
+
@formats = formats
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def resolve(name)
|
|
103
|
+
MAX_PARENTS.times do
|
|
104
|
+
data, parent = @cells[name]
|
|
105
|
+
return @formats[data] if data
|
|
106
|
+
return if parent.nil?
|
|
107
|
+
|
|
108
|
+
name = parent
|
|
109
|
+
end
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|