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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +124 -9
  4. data/lib/calcsheet/book.rb +25 -9
  5. data/lib/calcsheet/cell_range.rb +54 -0
  6. data/lib/calcsheet/configuration.rb +10 -0
  7. data/lib/calcsheet/display_format.rb +75 -0
  8. data/lib/calcsheet/errors.rb +5 -0
  9. data/lib/calcsheet/formula.rb +40 -0
  10. data/lib/calcsheet/limited_inflater.rb +16 -0
  11. data/lib/calcsheet/native_backends.rb +139 -0
  12. data/lib/calcsheet/option_validation.rb +4 -1
  13. data/lib/calcsheet/raw_row.rb +1 -1
  14. data/lib/calcsheet/readers/csv_reader.rb +43 -20
  15. data/lib/calcsheet/readers/csv_records.rb +193 -0
  16. data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
  17. data/lib/calcsheet/readers/ods/scanner.rb +33 -6
  18. data/lib/calcsheet/readers/ods_reader.rb +16 -1
  19. data/lib/calcsheet/readers/sax_sheet.rb +6 -48
  20. data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
  21. data/lib/calcsheet/reading.rb +29 -7
  22. data/lib/calcsheet/row.rb +2 -2
  23. data/lib/calcsheet/row_builder.rb +235 -0
  24. data/lib/calcsheet/sheet_list.rb +25 -0
  25. data/lib/calcsheet/sheet_selection.rb +3 -2
  26. data/lib/calcsheet/style.rb +114 -0
  27. data/lib/calcsheet/version.rb +1 -1
  28. data/lib/calcsheet/write_book.rb +49 -17
  29. data/lib/calcsheet/write_sheet.rb +220 -18
  30. data/lib/calcsheet/writers/csv_writer.rb +18 -7
  31. data/lib/calcsheet/writers/ods_writer.rb +249 -16
  32. data/lib/calcsheet/writers/open_formula.rb +67 -0
  33. data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
  34. data/lib/calcsheet/writers/zip_buffered.rb +20 -1
  35. data/lib/calcsheet.rb +31 -8
  36. 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, the trailing
64
- # blank cells are trimmed and, in header mode, the values padded with nil to the width of the header
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 [Array<SheetInfo>] every sheet of the workbook
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Calcsheet
4
4
  # The version of the gem.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
@@ -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: +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.
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>] see Column.from
153
- # @param header [Boolean] write the column headers as the first row
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 sheet
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:, header: true)
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, limit: @limit, zone: @zone,
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