calcsheet 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +124 -9
- data/lib/calcsheet/book.rb +25 -9
- data/lib/calcsheet/cell_range.rb +54 -0
- data/lib/calcsheet/configuration.rb +10 -0
- data/lib/calcsheet/display_format.rb +75 -0
- data/lib/calcsheet/errors.rb +5 -0
- data/lib/calcsheet/formula.rb +40 -0
- data/lib/calcsheet/limited_inflater.rb +16 -0
- data/lib/calcsheet/native_backends.rb +139 -0
- data/lib/calcsheet/option_validation.rb +4 -1
- data/lib/calcsheet/raw_row.rb +1 -1
- data/lib/calcsheet/readers/csv_reader.rb +43 -20
- data/lib/calcsheet/readers/csv_records.rb +193 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
- data/lib/calcsheet/readers/ods/scanner.rb +33 -6
- data/lib/calcsheet/readers/ods_reader.rb +16 -1
- data/lib/calcsheet/readers/sax_sheet.rb +6 -48
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
- data/lib/calcsheet/reading.rb +29 -7
- data/lib/calcsheet/row.rb +2 -2
- data/lib/calcsheet/row_builder.rb +235 -0
- data/lib/calcsheet/sheet_list.rb +25 -0
- data/lib/calcsheet/sheet_selection.rb +3 -2
- data/lib/calcsheet/style.rb +114 -0
- data/lib/calcsheet/version.rb +1 -1
- data/lib/calcsheet/write_book.rb +49 -17
- data/lib/calcsheet/write_sheet.rb +220 -18
- data/lib/calcsheet/writers/csv_writer.rb +18 -7
- data/lib/calcsheet/writers/ods_writer.rb +249 -16
- data/lib/calcsheet/writers/open_formula.rb +67 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
- data/lib/calcsheet/writers/zip_buffered.rb +20 -1
- data/lib/calcsheet.rb +31 -8
- metadata +11 -2
data/lib/calcsheet/row.rb
CHANGED
|
@@ -60,8 +60,8 @@ module Calcsheet
|
|
|
60
60
|
attr_reader :number
|
|
61
61
|
# @return [Integer, nil] CSV: the physical line where the record starts; nil for XLSX and ODS
|
|
62
62
|
attr_reader :line
|
|
63
|
-
# @return [Array] the values in file order, or in schema order with a schema. Without a schema,
|
|
64
|
-
#
|
|
63
|
+
# @return [Array] the values in file order, or in schema order with a schema. Without a schema, they are trimmed
|
|
64
|
+
# at the end (see Reading) and, in header mode, padded with nil to the width of the header
|
|
65
65
|
attr_reader :values
|
|
66
66
|
# @return [Array<CellError>] frozen; empty with +on_error: :raise+
|
|
67
67
|
attr_reader :errors
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cast"
|
|
4
|
+
require_relative "raw_row"
|
|
5
|
+
|
|
6
|
+
module Calcsheet
|
|
7
|
+
# Builds the RawRows of a reader from its raw cells: casts each cell with a Cast and the declared type of its
|
|
8
|
+
# column, records the failures, and trims the row after its last cell with a value (see Reading for the backend
|
|
9
|
+
# contract). The reader parses, places and checks the cells; the builder is where they become values and errors.
|
|
10
|
+
#
|
|
11
|
+
# A cell has a kind: :text (a string, its text as written), :number, :iso_date (an ISO date or datetime text),
|
|
12
|
+
# :duration (an ISO duration, "PT12H30M05S"), :boolean ("1", "0", "true" or "false"), :error (an error cell, its
|
|
13
|
+
# text the error) or :missing (a typed cell without its value, its reason given by +missing:+). A cell is
|
|
14
|
+
# non-blank unless it is a :text of whitespace only; an empty :text is no cell at all. A row without a non-blank
|
|
15
|
+
# cell is blank; the row of a non-blank cell ends with its last non-blank cell or, after it, its last whitespace
|
|
16
|
+
# text that the declared type keeps as text (:string, or the native value).
|
|
17
|
+
#
|
|
18
|
+
# The values Array is sparse: a cell sets only its own position, and #finish trims it. The builder keeps its state
|
|
19
|
+
# in instance variables reset per row, so a cell allocates nothing beyond its value and, when its cast fails, its
|
|
20
|
+
# error. It uses Cast::NON_BLANK for the blank test, so that it loads without Reading. A reader that has a whole row
|
|
21
|
+
# of texts takes the shortcut of #text_row instead.
|
|
22
|
+
#
|
|
23
|
+
# calcsheet-native depends on this interface: change it only in a minor release and together with the protocol
|
|
24
|
+
# version (see NativeBackends).
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
class RowBuilder
|
|
28
|
+
# The values of +number_errors:+.
|
|
29
|
+
NUMBER_ERRORS = %i[raw as_read].freeze
|
|
30
|
+
|
|
31
|
+
# @param cast [Cast]
|
|
32
|
+
# @param types [#[], nil] the declared type per column index (see Reading)
|
|
33
|
+
# @param number_errors [Symbol] the raw value of a failed cast: :raw, the cell's text (ODS, CSV); :as_read, for a
|
|
34
|
+
# :not_integral failure of a :number cell the number as read (Cast#integer_number), not the full print of a
|
|
35
|
+
# double that Excel writes (XLSX)
|
|
36
|
+
# @raise [ArgumentError] an unknown +number_errors+
|
|
37
|
+
def initialize(cast, types, number_errors: :raw)
|
|
38
|
+
unless NUMBER_ERRORS.include?(number_errors)
|
|
39
|
+
raise ArgumentError, "number_errors: must be :raw or :as_read, got #{number_errors.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@cast = cast
|
|
43
|
+
@types = types
|
|
44
|
+
@as_read = number_errors == :as_read
|
|
45
|
+
@values = []
|
|
46
|
+
@errors = nil
|
|
47
|
+
@last = -1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Adds the cell at +column+: its value, or the failure of its cast.
|
|
51
|
+
#
|
|
52
|
+
# @param column [Integer] 0-based
|
|
53
|
+
# @param kind [Symbol] see the class
|
|
54
|
+
# @param text [String] the raw cell, UTF-8
|
|
55
|
+
# @param format [Array(Symbol, Integer | nil), nil] for :number +[number_kind, pad]+ as Cast#number takes them
|
|
56
|
+
# (nil for :general without padding); for :iso_date only its kind is read, :date or :datetime, the padding is
|
|
57
|
+
# ignored (nil, or another kind, to let the text decide)
|
|
58
|
+
# @param missing [Symbol, nil] the reason of a :missing cell
|
|
59
|
+
# @return [nil]
|
|
60
|
+
# @raise [ArgumentError] an unknown +kind+
|
|
61
|
+
def add(column, kind, text, format = nil, missing: nil)
|
|
62
|
+
if kind == :text
|
|
63
|
+
return if text.empty?
|
|
64
|
+
|
|
65
|
+
@last = column if column > @last && !blank?(text)
|
|
66
|
+
elsif column > @last
|
|
67
|
+
@last = column
|
|
68
|
+
end
|
|
69
|
+
value = cast(kind, text, @types && @types[column], format, missing)
|
|
70
|
+
@values[column] = value unless value.nil?
|
|
71
|
+
nil
|
|
72
|
+
rescue Cast::Failure => failure
|
|
73
|
+
(@errors ||= []) << [ column, error_text(kind, text, failure), failure.reason ]
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The row of a reader that has all its cells as texts and already knows the last non-blank one (CSV finds it
|
|
78
|
+
# from the end of its fields): the same row as an #add of each text at its index and a #finish, without the
|
|
79
|
+
# state of the builder and the blank test of each text. The cost of a CSV row is mostly its casts, and these
|
|
80
|
+
# would add a twentieth to it. It does not touch the row being built by #add.
|
|
81
|
+
#
|
|
82
|
+
# @param texts [Array<String, nil>] the raw cells from column 0, UTF-8; a nil or empty one is no cell
|
|
83
|
+
# @param last [Integer] the index of the last non-blank text, at most +texts.size - 1+; the texts after it are
|
|
84
|
+
# whitespace, nil or empty
|
|
85
|
+
# @param number [Integer] see #finish
|
|
86
|
+
# @param line [Integer, nil] see #finish
|
|
87
|
+
# @return [RawRow, nil] nil when +last+ is negative
|
|
88
|
+
def text_row(texts, last, number, line: nil)
|
|
89
|
+
return if last.negative?
|
|
90
|
+
|
|
91
|
+
cast = @cast
|
|
92
|
+
types = @types
|
|
93
|
+
values = Array.new(last + 1)
|
|
94
|
+
errors = nil
|
|
95
|
+
column = 0
|
|
96
|
+
size = texts.size
|
|
97
|
+
while column < size
|
|
98
|
+
text = texts[column]
|
|
99
|
+
unless text.nil? || text.empty?
|
|
100
|
+
begin
|
|
101
|
+
value = cast.text(text, types && types[column])
|
|
102
|
+
values[column] = value unless value.nil?
|
|
103
|
+
rescue Cast::Failure => failure
|
|
104
|
+
(errors ||= []) << [ column, text, failure.reason ]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
column += 1
|
|
108
|
+
end
|
|
109
|
+
# In column order: only the failures of whitespace after the row can be beyond it.
|
|
110
|
+
errors.pop while errors&.last && errors.last[0] >= values.size
|
|
111
|
+
errors = nil if errors&.empty?
|
|
112
|
+
RawRow.new(number: number, line: line, values: values, errors: errors)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Adds the same raw cell to each column of +first..last+ (a repeated ODS cell): one value object, or one
|
|
116
|
+
# failure, per change of the declared type and the format of the columns.
|
|
117
|
+
#
|
|
118
|
+
# @param first [Integer] the first column, 0-based
|
|
119
|
+
# @param last [Integer] the last column
|
|
120
|
+
# @param kind [Symbol] see #add
|
|
121
|
+
# @param text [String] see #add
|
|
122
|
+
# @param missing [Symbol, nil] see #add
|
|
123
|
+
# @yieldparam column [Integer]
|
|
124
|
+
# @yieldreturn [Array(Symbol, Integer | nil), nil] the format of the column (see #add); without a block, nil
|
|
125
|
+
# @return [nil]
|
|
126
|
+
# @raise [ArgumentError] an unknown +kind+
|
|
127
|
+
def add_run(first, last, kind, text, missing: nil)
|
|
128
|
+
return if kind == :text && text.empty?
|
|
129
|
+
|
|
130
|
+
formatted = block_given?
|
|
131
|
+
type = format = value = failure = nil
|
|
132
|
+
index = first
|
|
133
|
+
while index <= last
|
|
134
|
+
wanted = @types && @types[index]
|
|
135
|
+
styled = formatted ? yield(index) : nil
|
|
136
|
+
# Identity, not equality: the formats are frozen Arrays shared by the cells of one style, so the same style
|
|
137
|
+
# gives the same object, and a check by identity costs nothing per column. An equal format of another style
|
|
138
|
+
# only casts once more.
|
|
139
|
+
if index == first || wanted != type || !styled.equal?(format)
|
|
140
|
+
type = wanted
|
|
141
|
+
format = styled
|
|
142
|
+
begin
|
|
143
|
+
failure = nil
|
|
144
|
+
value = cast(kind, text, wanted, styled, missing)
|
|
145
|
+
rescue Cast::Failure => error
|
|
146
|
+
failure = error
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
if failure then (@errors ||= []) << [ index, error_text(kind, text, failure), failure.reason ]
|
|
150
|
+
elsif !value.nil? then @values[index] = value
|
|
151
|
+
end
|
|
152
|
+
index += 1
|
|
153
|
+
end
|
|
154
|
+
@last = last if last > @last && (kind != :text || !blank?(text))
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Ends the row: the builder starts the next one empty.
|
|
159
|
+
#
|
|
160
|
+
# @param number [Integer] the row number the user sees
|
|
161
|
+
# @param line [Integer, nil] the physical line where it starts (CSV)
|
|
162
|
+
# @return [RawRow, nil] the row, trimmed after its last non-blank cell or whitespace text kept as text; nil when no
|
|
163
|
+
# cell is non-blank
|
|
164
|
+
def finish(number, line: nil)
|
|
165
|
+
if @last.negative?
|
|
166
|
+
@values.clear
|
|
167
|
+
@errors = nil
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
trim_row!
|
|
172
|
+
row = RawRow.new(number: number, line: line, values: @values, errors: @errors)
|
|
173
|
+
@values = []
|
|
174
|
+
@errors = nil
|
|
175
|
+
@last = -1
|
|
176
|
+
row
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def cast(kind, text, type, format, missing)
|
|
182
|
+
case kind
|
|
183
|
+
when :text then @cast.text(text, type)
|
|
184
|
+
when :number
|
|
185
|
+
format ? @cast.number(text, format[0], type, pad: format[1]) : @cast.number(text, :general, type)
|
|
186
|
+
when :iso_date then @cast.iso_date(text, type, format && format[0])
|
|
187
|
+
when :duration then @cast.duration(text, type)
|
|
188
|
+
when :boolean then @cast.boolean(boolean(text), type)
|
|
189
|
+
when :error then raise Cast::Failure.new(:cell_error, text)
|
|
190
|
+
when :missing then raise Cast::Failure.new(missing, text)
|
|
191
|
+
else raise ArgumentError, "unknown cell kind: #{kind.inspect}"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The raw value of a failed cast: the cell's text, or with +number_errors: :as_read+ the number as read for a
|
|
196
|
+
# :number cell that is not integral.
|
|
197
|
+
def error_text(kind, text, failure)
|
|
198
|
+
@as_read && failure.reason == :not_integral && kind == :number ? failure.value : text
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Ends the row with its last non-blank cell or, after it, its last whitespace text that got a value, and drops,
|
|
202
|
+
# like CSV, the cast failures of the whitespace after that. +@errors+ is nil when none is left, else sorted by
|
|
203
|
+
# column.
|
|
204
|
+
#
|
|
205
|
+
# Only a value sets a position, so the values Array already ends with the last cell that got one. A cell whose
|
|
206
|
+
# cast failed, or an error cell, sets nothing: when it is the last non-blank one and no whitespace text after it
|
|
207
|
+
# got a value, the Array is shorter than +@last+ + 1, and assigning nil at +@last+ extends it to that cell.
|
|
208
|
+
def trim_row!
|
|
209
|
+
@values[@last] = nil if @values.size <= @last
|
|
210
|
+
return unless @errors
|
|
211
|
+
|
|
212
|
+
width = @values.size
|
|
213
|
+
@errors.reject! { |error| error[0] >= width }
|
|
214
|
+
if @errors.empty? then @errors = nil
|
|
215
|
+
elsif @errors.size > 1 then @errors.sort_by!(&:first)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Whitespace only, without a regexp for the common text that starts with a printable ASCII character.
|
|
220
|
+
def blank?(text)
|
|
221
|
+
byte = text.getbyte(0)
|
|
222
|
+
!(byte > 32 && byte < 128) && !Cast::NON_BLANK.match?(text)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The texts of a boolean value in XLSX ("1", "0") and ODS ("true", "false"); both formats accept both. A case of
|
|
226
|
+
# String literals, which Ruby dispatches by hash, costs less per cell than a Hash or Array lookup.
|
|
227
|
+
def boolean(text)
|
|
228
|
+
case text
|
|
229
|
+
when "1", "true" then true
|
|
230
|
+
when "0", "false" then false
|
|
231
|
+
else raise Cast::Failure.new(:invalid_boolean, text)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Calcsheet
|
|
4
|
+
# The sheets of a book as a read selects them, from a backend that finds them only as far as they are read
|
|
5
|
+
# (+#each_sheet+, see Reading): the first visible sheet, or one named or indexed, costs the scan up to it, not of
|
|
6
|
+
# the whole file. +:all+ and the visible sheets read them all.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
class SheetList
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
# @param backend [#each_sheet]
|
|
13
|
+
def initialize(backend)
|
|
14
|
+
@backend = backend
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @yieldparam sheet [SheetInfo]
|
|
18
|
+
def each(&)
|
|
19
|
+
return enum_for(:each) unless block_given?
|
|
20
|
+
|
|
21
|
+
@backend.each_sheet(&)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -10,7 +10,8 @@ module Calcsheet
|
|
|
10
10
|
module SheetSelection
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
# @param sheets [
|
|
13
|
+
# @param sheets [Enumerable<SheetInfo>] every sheet of the workbook, in order: the default and a sheet given by
|
|
14
|
+
# index or name read only as far as they need (see SheetList)
|
|
14
15
|
# @param selector [nil, Integer, String, Array, :all] nil means the first visible sheet (the first sheet when
|
|
15
16
|
# every sheet is hidden)
|
|
16
17
|
# @param include_hidden [Boolean] :all includes the hidden sheets too; the default stays the first visible one.
|
|
@@ -22,7 +23,7 @@ module Calcsheet
|
|
|
22
23
|
selected =
|
|
23
24
|
case selector
|
|
24
25
|
when nil then [ sheets.find { |sheet| !sheet.hidden? } || sheets.first ].compact
|
|
25
|
-
when :all then include_hidden ? sheets : visible_or_first(sheets)
|
|
26
|
+
when :all then include_hidden ? sheets.to_a : visible_or_first(sheets)
|
|
26
27
|
when Integer, String then [ find(sheets, selector) ]
|
|
27
28
|
when Array
|
|
28
29
|
raise ArgumentError, "sheet: must not be an empty Array" if selector.empty?
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "display_format"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
# How written cells look in XLSX and ODS (CSV ignores it): registered by name with WriteBook#style and applied with
|
|
7
|
+
# WriteSheet#add. Every attribute is optional; nil keeps the default (Calibri 11 in XLSX, the default cell style in
|
|
8
|
+
# ODS, unbordered, the column's own number format).
|
|
9
|
+
#
|
|
10
|
+
# @attr_reader name [Symbol] the name rows refer to it by
|
|
11
|
+
# @attr_reader bold [Boolean, nil]
|
|
12
|
+
# @attr_reader italic [Boolean, nil]
|
|
13
|
+
# @attr_reader size [Numeric, nil] the font size in points, 1 to 409
|
|
14
|
+
# @attr_reader color [String, nil] the font colour, "RRGGBB"
|
|
15
|
+
# @attr_reader fill [String, nil] the background colour, "RRGGBB"
|
|
16
|
+
# @attr_reader align [Symbol, nil] +:left+, +:center+ or +:right+
|
|
17
|
+
# @attr_reader valign [Symbol, nil] +:top+, +:center+ or +:bottom+
|
|
18
|
+
# @attr_reader wrap [Boolean, nil] wrap the text in the cell
|
|
19
|
+
# @attr_reader border [Style::Border, nil]
|
|
20
|
+
# @attr_reader format [DisplayFormat, nil] the number format, instead of the column's
|
|
21
|
+
Style = Data.define(:name, :bold, :italic, :size, :color, :fill, :align, :valign, :wrap, :border, :format) do
|
|
22
|
+
# @raise [ArgumentError] on an invalid attribute
|
|
23
|
+
def initialize(name:, bold: nil, italic: nil, size: nil, color: nil, fill: nil, align: nil, valign: nil, wrap: nil,
|
|
24
|
+
border: nil, format: nil)
|
|
25
|
+
raise ArgumentError, "style: the name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
|
|
26
|
+
|
|
27
|
+
super(name: name, bold: Style.flag(:bold, bold), italic: Style.flag(:italic, italic), size: Style.size(size),
|
|
28
|
+
color: Style.color(:color, color), fill: Style.color(:fill, fill),
|
|
29
|
+
align: Style.choice(:align, align, %i[left center right]), valign: Style.choice(:valign, valign, %i[top center bottom]),
|
|
30
|
+
wrap: Style.flag(:wrap, wrap), border: border.nil? || border.is_a?(Style::Border) ? border : Style::Border.from(border),
|
|
31
|
+
format: format.nil? || format.is_a?(DisplayFormat) ? format : DisplayFormat.parse(format))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] whether the style changes nothing of the default but the number format
|
|
35
|
+
def plain?
|
|
36
|
+
[ bold, italic, size, color, fill, align, valign, wrap, border ].all?(&:nil?)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Style
|
|
41
|
+
# The font sizes Excel accepts.
|
|
42
|
+
SIZES = (1..409)
|
|
43
|
+
# A colour: six hexadecimal digits, or eight (ARGB, whose alpha is dropped), with or without a leading "#".
|
|
44
|
+
COLOR = /\A#?(?:\h{2})?(\h{6})\z/
|
|
45
|
+
|
|
46
|
+
# The border of a styled cell: a line style and colour on some of its edges.
|
|
47
|
+
#
|
|
48
|
+
# @attr_reader style [Symbol] one of STYLES
|
|
49
|
+
# @attr_reader color [String] "RRGGBB"
|
|
50
|
+
# @attr_reader edges [Array<Symbol>] some of EDGES, in that order
|
|
51
|
+
Border = Data.define(:style, :color, :edges) do
|
|
52
|
+
# @param entry [Hash] +{ style: :thin, color: "000000", edges: %i[top bottom left right] }+
|
|
53
|
+
# @return [Border]
|
|
54
|
+
# @raise [ArgumentError] on an invalid entry
|
|
55
|
+
def self.from(entry)
|
|
56
|
+
raise ArgumentError, "style: border: expected a Hash, got #{entry.inspect}" unless entry.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
unknown = entry.keys - members
|
|
59
|
+
raise ArgumentError, "style: border: unknown option(s) #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
|
|
60
|
+
|
|
61
|
+
new(**entry)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(style: :thin, color: "000000", edges: Border::EDGES)
|
|
65
|
+
Style.choice(:"border style", style, Border::STYLES)
|
|
66
|
+
unless edges.is_a?(Array) && !edges.empty? && (edges - Border::EDGES).empty?
|
|
67
|
+
raise ArgumentError, "style: border: edges must be some of #{Border::EDGES.inspect}, got #{edges.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
super(style: style, color: Style.color(:"border color", color), edges: (Border::EDGES & edges).freeze)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Border
|
|
75
|
+
# The line styles, by their XLSX names.
|
|
76
|
+
STYLES = %i[thin medium thick dashed dotted double].freeze
|
|
77
|
+
EDGES = %i[top bottom left right].freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# @api private
|
|
82
|
+
def flag(name, value)
|
|
83
|
+
return value if value.nil? || value == true || value == false
|
|
84
|
+
|
|
85
|
+
raise ArgumentError, "style: #{name}: must be true, false or nil, got #{value.inspect}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @api private
|
|
89
|
+
def size(value)
|
|
90
|
+
return value if value.nil? || (value.is_a?(Numeric) && value.real? && SIZES.cover?(value))
|
|
91
|
+
|
|
92
|
+
raise ArgumentError, "style: size: must be a number from #{SIZES.min} to #{SIZES.max}, got #{value.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @api private
|
|
96
|
+
# @return [String, nil] "RRGGBB", upper case
|
|
97
|
+
def color(name, value)
|
|
98
|
+
return if value.nil?
|
|
99
|
+
|
|
100
|
+
match = value.is_a?(String) && COLOR.match(value)
|
|
101
|
+
return match[1].upcase.freeze if match
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "style: #{name}: must be a colour such as \"81B7C0\", got #{value.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @api private
|
|
107
|
+
def choice(name, value, allowed)
|
|
108
|
+
return value if value.nil? || allowed.include?(value)
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, "style: #{name}: must be one of #{allowed.map(&:inspect).join(", ")}, got #{value.inspect}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/calcsheet/version.rb
CHANGED
data/lib/calcsheet/write_book.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "formula"
|
|
4
5
|
require_relative "sheet_names"
|
|
6
|
+
require_relative "style"
|
|
5
7
|
require_relative "time_zone"
|
|
6
8
|
require_relative "write_sheet"
|
|
7
9
|
|
|
@@ -22,15 +24,22 @@ module Calcsheet
|
|
|
22
24
|
# call, which win over presets. It runs before anything is written, also when Calcsheet.stream builds its body.
|
|
23
25
|
# - +.new(io, options)+: +io+ responds to +write(string)+ and +<<(string)+ and may be non-seekable; +options+ are
|
|
24
26
|
# those prepared, frozen. The backend must not close +io+.
|
|
25
|
-
# - +#start_sheet(name, columns, header:)+, for each sheet and each continuation:
|
|
26
|
-
# +columns+ an Array of frozen Column,
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# written:
|
|
27
|
+
# - +#start_sheet(name, columns, header:, widths: nil, auto_width: false)+, for each sheet and each continuation:
|
|
28
|
+
# +name+ is valid and unique, +columns+ an Array of frozen Column, or nil for a free sheet (no columns: each
|
|
29
|
+
# cell has the type of its value, see #row), whose column widths +widths+ gives (an Array of Numeric or nil, by
|
|
30
|
+
# column index); with +header: true+ (never for a free sheet) the backend writes the header row (the column
|
|
31
|
+
# headers) itself. With +auto_width: true+ the widths of the columns without one come with #end_sheet, after the
|
|
32
|
+
# rows: the backend keeps the rows (in a temporary file) until then.
|
|
33
|
+
# - +#row(values, styles = nil)+: one value per column, in column order: nil (empty cell), a Formula, or a value of
|
|
34
|
+
# the column type — String in UTF-8 (:string), Integer (:integer), Integer or finite BigDecimal (:decimal,
|
|
35
|
+
# written unrounded; +scale+ is display only), Date (:date), true/false (:boolean), and for :datetime a UTC
|
|
36
|
+
# Time in whole seconds whose fields are the wall clock to write (the +time_zone:+ conversion is already
|
|
37
|
+
# applied). In a free sheet the values may be fewer or more than in other rows, and each one's class is its type.
|
|
38
|
+
# +styles+ is nil or an Array of Style or nil by column index, possibly longer than +values+: a styled nil is a
|
|
39
|
+
# styled empty cell. A value the format cannot hold (a date beyond its range, a text too long) raises WriteError,
|
|
40
|
+
# and nothing of the row may stay written: the sheet goes on with the next row.
|
|
41
|
+
# - +#merge(range)+ (optional; CSV has none): a CellRange of the sheet to merge, declared before the row of its
|
|
42
|
+
# first cell is written, never overlapping another; the cells it covers but the first are nil (styles allowed).
|
|
34
43
|
# - +#text_bytesize(text, column_index)+ (optional): the bytes the backend writes for a text of that column, when
|
|
35
44
|
# it adds to it (the CSV formula prefix); the facade asks it only for a text of +max_cell_bytes+ bytes or more,
|
|
36
45
|
# and refuses one the readers would refuse with a WriteError. Without it, the bytes of the text count.
|
|
@@ -40,7 +49,8 @@ module Calcsheet
|
|
|
40
49
|
# - +#row_problem(values)+ (optional): the row #row would refuse with a WriteError, as +[column_index, reason]+, or
|
|
41
50
|
# nil; asked, without writing, for the row that overflows into a continuation sheet, so that a refused row
|
|
42
51
|
# opens none.
|
|
43
|
-
# - +#end_sheet+ after the last row of each sheet
|
|
52
|
+
# - +#end_sheet(widths = nil)+ after the last row of each sheet: with +auto_width: true+, the width of every
|
|
53
|
+
# column (an Array of Numeric or nil by column index), measured on the rows written or given.
|
|
44
54
|
# - +#finish+ once, after the last sheet, also when the book has none: writes what completes the file.
|
|
45
55
|
#
|
|
46
56
|
# When the caller's block raises, the facade stops calling the backend and the output is left incomplete.
|
|
@@ -140,31 +150,53 @@ module Calcsheet
|
|
|
140
150
|
@limit = options.max_rows_per_sheet
|
|
141
151
|
@max_cell_bytes = options.max_cell_bytes
|
|
142
152
|
@names = SheetNames.new(sanitize: options.sanitize_sheet_names)
|
|
153
|
+
@styles = {}
|
|
143
154
|
@sheet = nil
|
|
144
155
|
@failed = nil
|
|
145
156
|
@finished = false
|
|
146
157
|
end
|
|
147
158
|
|
|
159
|
+
# Registers a style that rows of any sheet can use by name (WriteSheet#add). XLSX and ODS apply it; CSV ignores
|
|
160
|
+
# it.
|
|
161
|
+
#
|
|
162
|
+
# @param name [Symbol]
|
|
163
|
+
# @param attributes [Hash] see Style: +bold:+, +italic:+, +size:+, +color:+, +fill:+, +align:+, +valign:+,
|
|
164
|
+
# +wrap:+, +border:+ (+{ style:, color:, edges: }+) and +format:+ (see DisplayFormat)
|
|
165
|
+
# @return [Style]
|
|
166
|
+
# @raise [ArgumentError] an invalid attribute, or a name already registered
|
|
167
|
+
def style(name, **attributes)
|
|
168
|
+
check_usable
|
|
169
|
+
raise ArgumentError, "style #{name.inspect} is already defined" if @styles.key?(name)
|
|
170
|
+
|
|
171
|
+
@styles[name] = Style.new(name: name, **attributes)
|
|
172
|
+
end
|
|
173
|
+
|
|
148
174
|
# Writes one sheet: yields it, then ends it. When the block raises (or leaves with break or throw), the sheet is
|
|
149
175
|
# left incomplete and the book refuses any further sheet or #finish with a WriteError.
|
|
150
176
|
#
|
|
151
177
|
# @param name [String] valid and unique (see SheetNames), unless +sanitize_sheet_names: true+ repairs it
|
|
152
|
-
# @param columns [Array<Hash, Column
|
|
153
|
-
#
|
|
178
|
+
# @param columns [Array<Hash, Column>, nil] see Column.from; nil for a free sheet, whose rows hold any number of
|
|
179
|
+
# values, each written with the type of its class (see WriteSheet#add)
|
|
180
|
+
# @param header [Boolean, nil] write the column headers as the first row; default true, never for a free sheet
|
|
181
|
+
# @param widths [Array<Numeric, nil>, nil] a free sheet's column widths in characters, by column index (a typed
|
|
182
|
+
# sheet has its columns' +width:+)
|
|
183
|
+
# @param auto_width [Boolean] the columns without a width get the width of their longest value (XLSX and ODS):
|
|
184
|
+
# the rows are kept in a temporary file until the sheet ends
|
|
154
185
|
# @yieldparam sheet [WriteSheet]
|
|
155
186
|
# @return [nil]
|
|
156
|
-
# @raise [ArgumentError] a missing block, invalid columns or name, too many columns for the format, a
|
|
157
|
-
# opened inside another one's block, or a book already finished
|
|
187
|
+
# @raise [ArgumentError] a missing block, invalid columns, widths or name, too many columns for the format, a
|
|
188
|
+
# sheet opened inside another one's block, or a book already finished
|
|
158
189
|
# @raise [WriteError] an earlier sheet block raised; a second sheet in CSV; a header longer than an XLSX cell, or
|
|
159
190
|
# (written) than +max_cell_bytes+; the sheet is not started
|
|
160
|
-
def sheet(name, columns
|
|
191
|
+
def sheet(name, columns: nil, header: nil, widths: nil, auto_width: false)
|
|
161
192
|
raise ArgumentError, "a block is required" unless block_given?
|
|
162
193
|
|
|
163
194
|
check_usable
|
|
164
195
|
raise ArgumentError, "sheet #{Untrusted.show(@sheet.name)} is still open" if @sheet
|
|
165
196
|
|
|
166
|
-
sheet = @sheet = WriteSheet.new(@writer, @names, name, columns, header: header
|
|
167
|
-
max_cell_bytes: @max_cell_bytes
|
|
197
|
+
sheet = @sheet = WriteSheet.new(@writer, @names, name, columns, header: header.nil? ? !columns.nil? : header,
|
|
198
|
+
limit: @limit, zone: @zone, max_cell_bytes: @max_cell_bytes, styles: @styles,
|
|
199
|
+
widths: widths, auto_width: auto_width)
|
|
168
200
|
completed = false
|
|
169
201
|
begin
|
|
170
202
|
yield sheet
|