calcsheet 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 291f855f2646898ede484b6dbaca57d4cac890387ece27c70352fbb463770057
4
- data.tar.gz: 4cb2e6174412b74611168b5da6daf2844fcd8f33ae5c1d88b6b4f528b777267a
3
+ metadata.gz: 0cb9f770dac27dff3dcc72ce14a9c5183d2654f9e8ded440f6b62c9b90b970ad
4
+ data.tar.gz: b6c5a2c6488c6ebf0ce1c1efd486ae352f17fac6448dee01f94ba4cbb26d20fb
5
5
  SHA512:
6
- metadata.gz: c10916111f1bd9d947e7964642b7e6db53e6af1e5acd98e43c8c98067c6212a4a272d6e0a753b3cd33d5f91403f858e3b93108d7e15a8d11e11a20959dfefdfe
7
- data.tar.gz: b6ee874c89945474f843bed12c44b68a84ad8e56d81bc58528a783e513fa34009c53f419340aa37c2d0d5c006f553a05abdfd22baa1145b2564715e3fe77042b
6
+ metadata.gz: cbb045fef9e6b52af84af31460584209c1ffc425d88c551f11508b732f4e728ece01c6ef82cef3dc41e360e53fa063c68eed6c1631a36a9b16dca29ab0025c61
7
+ data.tar.gz: 5c6aa201e3120996d3f0cf965556ae1c3cb039c0cf6d4b6d900ecc26d04bebf3d4aa7f92bad6183090a5e53b347ed16dc5dd5169c8d5e3ade8ab7d90b4a28a38
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - `Calcsheet::Formula.new(text, value:)` stores the formula's result, which XLSX and ODS applications that do not
15
+ compute formulas show and calcsheet reads back (ADR 016).
16
+ - A column's `style:` styles its data cells that a row leaves unstyled; `book.sheet(..., header_style:)` writes the
17
+ header row in a style instead of bold.
18
+ - `auto_width` measures a number as its style's number format or its column's `scale:` shows it, and a formula by
19
+ its stored result.
20
+
21
+ ### Changed
22
+
23
+ - Styled rows are written about twice as fast: the XLSX and ODS writers look up each style once per number format
24
+ instead of hashing it for every cell.
25
+
10
26
  ## [0.2.0] - 2026-10-22
11
27
 
12
28
  ### Added
@@ -98,6 +114,7 @@ First release.
98
114
  - Report vulnerabilities as described in
99
115
  [SECURITY.md](https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/blob/main/SECURITY.md).
100
116
 
101
- [Unreleased]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/compare/v0.2.0...develop
117
+ [Unreleased]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/compare/v0.3.0...develop
118
+ [0.3.0]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/compare/v0.2.0...v0.3.0
102
119
  [0.2.0]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/compare/v0.1.0...v0.2.0
103
120
  [0.1.0]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/tags/v0.1.0
data/README.md CHANGED
@@ -45,7 +45,7 @@ Calcsheet will be published on [rubygems.org](https://rubygems.org/gems/calcshee
45
45
  release. Once it is released, add it to your Gemfile:
46
46
 
47
47
  ```rb
48
- gem "calcsheet", "~> 0.2"
48
+ gem "calcsheet", "~> 0.3"
49
49
  ```
50
50
 
51
51
  The runtime dependencies are `ox` (a SAX parser with a C extension), `rubyzip`, `zip_kit`, `bigdecimal` and `csv`.
@@ -322,6 +322,7 @@ are written one after another, and a finished sheet cannot be reopened. Each col
322
322
  | `scale:` | `nil` | Decimals shown by XLSX and ODS for a `:decimal` column. Display only: see below. |
323
323
  | `key:` | `nil` | The key of the column in Hash rows, instead of the header. |
324
324
  | `formula_protection:` | `true` | CSV only: see [formula protection](#formula-protection). |
325
+ | `style:` | `nil` | The name of a style for the column's data cells (XLSX and ODS): see [styles](#styles-formulas-and-merges). |
325
326
 
326
327
  `sheet << row` takes an Array in column order or a Hash (an unknown key raises `ArgumentError`). Every value must
327
328
  fit its column type, otherwise `Calcsheet::WriteError` names the sheet, row and column, nothing of that row is
@@ -359,19 +360,27 @@ Calcsheet.write("report.xlsx") do |book|
359
360
  book.style(:head, bold: true, fill: "DDEEFF", wrap: true, valign: :top,
360
361
  border: { style: :thin, color: "000000", edges: %i[top bottom] })
361
362
  book.style(:percent, format: "0.0 %")
363
+ book.style(:money, format: "#,##0.00")
362
364
 
363
365
  book.sheet("Answers", auto_width: true) do |sheet| # no columns: a free sheet
364
366
  sheet.merge("A1:C1") # before the row it starts on
365
367
  sheet.add(["Answers to question 1", nil, nil], style: :title)
366
368
  sheet.add(["Answer", "Count", "Share"], style: :head)
367
- sheet << ["Yes", 30, Calcsheet::Formula.new("B3/B5")]
368
- sheet << ["No", 10, Calcsheet::Formula.new("B4/B5")]
369
- sheet.add(["Total", Calcsheet::Formula.new("SUM(B3:B4)"), 1], style: [nil, nil, :percent])
369
+ sheet.add(["Yes", 30, Calcsheet::Formula.new("B3/B5")], style: [nil, nil, :percent])
370
+ sheet.add(["No", 10, Calcsheet::Formula.new("B4/B5")], style: [nil, nil, :percent])
371
+ sheet << ["Total", Calcsheet::Formula.new("SUM(B3:B4)", value: 40)] # with its result
372
+ end
373
+
374
+ book.sheet("Prices", header_style: :head, columns: [
375
+ { header: "Code" },
376
+ { header: "Price", type: :decimal, style: :money } # the style of the column's cells
377
+ ]) do |sheet|
378
+ sheet << ["00013", BigDecimal("1234.5")]
370
379
  end
371
380
  end
372
381
 
373
382
  Calcsheet.open("report.xlsx") do |book|
374
- book.rows(header: false).map(&:values).last # => ["Total", nil, 1]
383
+ book.rows(header: false).map(&:values).last # => ["Total", 40]
375
384
  end
376
385
  ```
377
386
 
@@ -383,22 +392,26 @@ end
383
392
  `"0.0 %"` (0.25 shows as 25.0 %), dates and times from `yyyy`, `mm`, `dd`, `hh`, `ss` (`"dd/mm/yyyy"`, `"hh:mm"`),
384
393
  or `"@"` for text. An unknown attribute or value raises `ArgumentError`. `sheet.add(row, style:)` writes a row as
385
394
  `<<` does, with the name of a style for every cell or an Array of names (or `nil`) by column; a style given for a
386
- column beyond the values styles an empty cell (the border of a merged title). XLSX and ODS apply styles, CSV ignores
387
- them.
395
+ column beyond the values styles an empty cell (the border of a merged title). In a sheet with columns, a column's
396
+ `style:` styles its data cells that the row leaves unstyled, and `book.sheet(..., header_style: :name)` writes the
397
+ header row in that style instead of bold. XLSX and ODS apply styles, CSV ignores them.
388
398
 
389
399
  - **Free sheets.** `book.sheet(name)` without `columns:` writes rows of any length (Arrays only, no header row), each
390
400
  value with the type of its class: `String`, `Integer`, `BigDecimal`, `Date`, `Time`, `true`, `false`; a `Float` or
391
401
  a `DateTime` raises `WriteError`, as in a typed column. `widths: [12, nil, 30]` sets their column widths.
392
402
  - **Formulas.** `Calcsheet::Formula.new("SUM(B3:B4)")` (a leading `=` is optional) writes a formula in any sheet and
393
403
  column, in the syntax of Excel: ODS gets it translated to OpenFormula (`of:=SUM([.B3:.B4])`, `;` between
394
- arguments), CSV writes the text `=SUM(B3:B4)`. No result is stored: Excel and LibreOffice compute it when they open
395
- the file, and calcsheet reads such a cell back as `nil`.
404
+ arguments), CSV writes the text `=SUM(B3:B4)`. Excel and LibreOffice compute it when they open the file;
405
+ `Formula.new("B3/B5", value: BigDecimal("0.75"))` also stores its result (a `String`, an `Integer`, a finite
406
+ `BigDecimal`, `true` or `false`), which applications that do not compute formulas (previews, calcsheet itself) show.
407
+ Without it they show an empty cell, and calcsheet reads it back as `nil`.
396
408
  - **Merges.** `sheet.merge("A1:C1")` merges a range, rows counted from the first of the sheet (the header included).
397
409
  ODS writes a merge on its first cell, so it must be declared before that row is written; the other cells it covers
398
410
  must be empty (`WriteError` otherwise), and a merge may not overlap another. CSV ignores merges.
399
411
  - **`auto_width: true`** gives each column without a `width:` the width of its longest value, headers included, in
400
- characters scaled by the style's font size and weight (a rough measure, as Excel's own autofit is). Formulas and
401
- the first cell of a merge across columns are not measured; `sheet.add(row, measure: false)` leaves out a row such
412
+ characters scaled by the style's font size and weight (a rough measure, as Excel's own autofit is); a number counts
413
+ as its style's number format or its column's `scale:` shows it, a formula as its stored result. Formulas without
414
+ one and the first cell of a merge across columns are not measured; `sheet.add(row, measure: false)` leaves out a row such
402
415
  as a long note. The widths come before the rows in both formats, so the rows of an `auto_width` sheet wait in a
403
416
  temporary file until the sheet ends.
404
417
 
@@ -765,7 +778,7 @@ Reading:
765
778
 
766
779
  Writing:
767
780
 
768
- - The header row is bold and frozen (XLSX `pane`, ODS `settings.xml`) when `header: true`. Columns get their
781
+ - The header row is bold (or in its `header_style:`) and frozen (XLSX `pane`, ODS `settings.xml`) when `header: true`. Columns get their
769
782
  `width:`; ODS widths are rounded to whole characters, from 1 to 255.
770
783
  - Styles (see [styles, formulas and merges](#styles-formulas-and-merges)) are XLSX cell formats with their fonts,
771
784
  fills and borders, and ODS common cell styles `Calcsheet1`, `Calcsheet2`... in `styles.xml`. A workbook with
@@ -905,7 +918,7 @@ never needs a Rust toolchain.
905
918
  nothing in your code changes:
906
919
 
907
920
  ```rb
908
- gem "calcsheet", "~> 0.2"
921
+ gem "calcsheet", "~> 0.3"
909
922
  gem "calcsheet-native"
910
923
  ```
911
924
 
@@ -15,11 +15,16 @@ module Calcsheet
15
15
  # @attr_reader key [Object, nil] the key of the column in Hash rows, instead of the header
16
16
  # @attr_reader formula_protection [Boolean] CSV prefixes text starting with a formula character with "'" (see
17
17
  # FormulaProtection): the cells of a +:string+ column, and the header of any column; default true
18
- Column = Data.define(:header, :type, :width, :scale, :key, :formula_protection) do
18
+ # @attr_reader style [Symbol, nil] the name of a style of the book (WriteBook#style) for the column's data cells
19
+ # that a row does not style (XLSX and ODS)
20
+ Column = Data.define(:header, :type, :width, :scale, :key, :formula_protection, :style) do
19
21
  # @raise [ArgumentError] on an invalid attribute
20
- def initialize(header: nil, type: :string, width: nil, scale: nil, key: nil, formula_protection: true)
22
+ def initialize(header: nil, type: :string, width: nil, scale: nil, key: nil, formula_protection: true, style: nil)
23
+ raise ArgumentError, "column: style: must be a Symbol or nil, got #{style.inspect}" unless style.nil? || style.is_a?(Symbol)
24
+
21
25
  super(header: Column.checked_header(header), type: Column.checked_type(type), width: Column.checked_width(width),
22
- scale: Column.checked_scale(scale), key: key, formula_protection: Column.checked_protection(formula_protection))
26
+ scale: Column.checked_scale(scale), key: key, formula_protection: Column.checked_protection(formula_protection),
27
+ style: style)
23
28
  end
24
29
 
25
30
  # A copy with +changes+, validated like +new+. Defined here because Data#with does not call +initialize+ on
@@ -41,7 +46,8 @@ module Calcsheet
41
46
  MAX_WIDTH = 255
42
47
 
43
48
  class << self
44
- # @param entry [Hash, Column] { header:, type: :string, width: nil, scale: nil, key: nil, formula_protection: true }
49
+ # @param entry [Hash, Column] { header:, type: :string, width: nil, scale: nil, key: nil, formula_protection: true,
50
+ # style: nil }
45
51
  # @return [Column]
46
52
  # @raise [ArgumentError] on an invalid entry
47
53
  def from(entry)
@@ -1,19 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
3
4
  require_relative "column"
4
5
 
5
6
  module Calcsheet
6
7
  # A formula to write in a cell, in the syntax of Excel: +Formula.new("SUM(B2:B5)")+ (a leading "=" is dropped).
7
8
  # XLSX writes it as is, ODS translates its references and argument separators to OpenFormula, CSV writes it as the
8
- # text "=SUM(B2:B5)". No value is cached with it: Excel and LibreOffice compute it when they open the file, and
9
- # calcsheet reads such a cell back as nil.
9
+ # text "=SUM(B2:B5)". Excel and LibreOffice compute it when they open the file. Its +value:+, when given, is
10
+ # stored with it as its result: applications and readers that do not compute formulas (previews, calcsheet itself)
11
+ # show it, and without it they show an empty cell (calcsheet reads it as nil).
10
12
  #
11
13
  # @attr_reader text [String] the formula without its leading "=", frozen UTF-8
12
- Formula = Data.define(:text) do
14
+ # @attr_reader value [String, Integer, BigDecimal, Boolean, nil] the stored result, nil for none
15
+ Formula = Data.define(:text, :value) do
16
+ class << self
17
+ alias_method :data_new, :new
18
+
19
+ # Takes the text as the only positional argument, with or without +value:+:
20
+ # +Formula.new("B3/B5", value: BigDecimal("0.6"))+.
21
+ def new(*arguments, **keywords)
22
+ arguments.size == 1 ? data_new(text: arguments.first, **keywords) : data_new(*arguments, **keywords)
23
+ end
24
+ end
25
+
13
26
  # @param text [String]
27
+ # @param value [String, Integer, BigDecimal, true, false, nil] the result to store; a Float is refused, as in a
28
+ # +:decimal+ column, so that no binary rounding slips in
14
29
  # @raise [ArgumentError] unless +text+ is a non-empty String convertible to UTF-8, of at most MAX_LENGTH
15
- # characters
16
- def initialize(text:)
30
+ # characters, and +value+ one of those
31
+ def initialize(text:, value: nil)
17
32
  unless text.is_a?(String) && (utf8 = Column.utf8(text))
18
33
  raise ArgumentError, "formula: expected a String convertible to UTF-8, got #{text.inspect}"
19
34
  end
@@ -24,7 +39,7 @@ module Calcsheet
24
39
  raise ArgumentError, "formula: #{utf8.length} characters, more than the #{Formula::MAX_LENGTH} of Excel"
25
40
  end
26
41
 
27
- super(text: utf8.freeze)
42
+ super(text: utf8.freeze, value: Formula.result(value))
28
43
  end
29
44
 
30
45
  # @return [String] the formula as a spreadsheet shows it, with its "="
@@ -36,5 +51,23 @@ module Calcsheet
36
51
  class Formula
37
52
  # The longest formula Excel accepts, in characters.
38
53
  MAX_LENGTH = 8_192
54
+ # @api private
55
+ # The cell type a stored result is written as, by its class.
56
+ RESULT_TYPES = { String => :string, Integer => :integer, BigDecimal => :decimal, TrueClass => :boolean,
57
+ FalseClass => :boolean }.freeze
58
+
59
+ # @api private
60
+ # A checked result: a String as frozen UTF-8.
61
+ def self.result(value)
62
+ case value
63
+ when nil, true, false, Integer then return value
64
+ when BigDecimal then return value if value.finite?
65
+ when String
66
+ utf8 = Column.utf8(value)
67
+ return utf8.frozen? ? utf8 : utf8.dup.freeze if utf8
68
+ end
69
+ raise ArgumentError, "formula: value: expected a String, an Integer, a finite BigDecimal, true, false or nil, " \
70
+ "got #{value.inspect}"
71
+ end
39
72
  end
40
73
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Calcsheet
4
4
  # The version of the gem.
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
@@ -24,12 +24,13 @@ module Calcsheet
24
24
  # call, which win over presets. It runs before anything is written, also when Calcsheet.stream builds its body.
25
25
  # - +.new(io, options)+: +io+ responds to +write(string)+ and +<<(string)+ and may be non-seekable; +options+ are
26
26
  # those prepared, frozen. The backend must not close +io+.
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.
27
+ # - +#start_sheet(name, columns, header:, widths: nil, auto_width: false, header_style: nil)+, for each sheet and
28
+ # each continuation: +name+ is valid and unique, +columns+ an Array of frozen Column, or nil for a free sheet
29
+ # (no columns: each cell has the type of its value, see #row), whose column widths +widths+ gives (an Array of
30
+ # Numeric or nil, by column index); with +header: true+ (never for a free sheet) the backend writes the header row (the column
31
+ # headers) itself, bold, or in the Style +header_style+ when given. With +auto_width: true+ the widths of the
32
+ # columns without one come with #end_sheet, after the rows: the backend keeps the rows (in a temporary file)
33
+ # until then. The facade passes only the keywords a sheet uses beyond +header:+.
33
34
  # - +#row(values, styles = nil)+: one value per column, in column order: nil (empty cell), a Formula, or a value of
34
35
  # the column type — String in UTF-8 (:string), Integer (:integer), Integer or finite BigDecimal (:decimal,
35
36
  # written unrounded; +scale+ is display only), Date (:date), true/false (:boolean), and for :datetime a UTC
@@ -182,13 +183,15 @@ module Calcsheet
182
183
  # sheet has its columns' +width:+)
183
184
  # @param auto_width [Boolean] the columns without a width get the width of their longest value (XLSX and ODS):
184
185
  # the rows are kept in a temporary file until the sheet ends
186
+ # @param header_style [Symbol, nil] the name of a style (#style) for the header row instead of bold (XLSX and
187
+ # ODS)
185
188
  # @yieldparam sheet [WriteSheet]
186
189
  # @return [nil]
187
190
  # @raise [ArgumentError] a missing block, invalid columns, widths or name, too many columns for the format, a
188
191
  # sheet opened inside another one's block, or a book already finished
189
192
  # @raise [WriteError] an earlier sheet block raised; a second sheet in CSV; a header longer than an XLSX cell, or
190
193
  # (written) than +max_cell_bytes+; the sheet is not started
191
- def sheet(name, columns: nil, header: nil, widths: nil, auto_width: false)
194
+ def sheet(name, columns: nil, header: nil, widths: nil, auto_width: false, header_style: nil)
192
195
  raise ArgumentError, "a block is required" unless block_given?
193
196
 
194
197
  check_usable
@@ -196,7 +199,7 @@ module Calcsheet
196
199
 
197
200
  sheet = @sheet = WriteSheet.new(@writer, @names, name, columns, header: header.nil? ? !columns.nil? : header,
198
201
  limit: @limit, zone: @zone, max_cell_bytes: @max_cell_bytes, styles: @styles,
199
- widths: widths, auto_width: auto_width)
202
+ widths: widths, auto_width: auto_width, header_style: header_style)
200
203
  completed = false
201
204
  begin
202
205
  yield sheet
@@ -59,7 +59,7 @@ module Calcsheet
59
59
  # @raise [ArgumentError] on invalid columns, name, limit or widths
60
60
  # @raise [WriteError] for a written header longer than +max_cell_bytes+, or one the backend refuses
61
61
  def initialize(writer, names, name, columns, header:, limit:, zone:, max_cell_bytes: nil, styles: {}, widths: nil,
62
- auto_width: false)
62
+ auto_width: false, header_style: nil)
63
63
  if columns.nil?
64
64
  raise ArgumentError, "header: a sheet without columns: has no header" if header
65
65
  elsif !columns.is_a?(Array) || columns.empty?
@@ -76,6 +76,13 @@ module Calcsheet
76
76
  @header = header
77
77
  @styles = styles
78
78
  @auto_width = auto_width
79
+ @column_styles = @columns&.map { _1.style && lookup_style(_1.style) }
80
+ @column_styles = nil unless @column_styles&.any?
81
+ if header_style && !@header
82
+ raise ArgumentError, "header_style: a sheet without a header row has no header to style"
83
+ end
84
+
85
+ @header_style = header_style && lookup_style(header_style)
79
86
  if @header && limit && limit < 2
80
87
  raise ArgumentError, "max_rows_per_sheet: #{limit} leaves no room for data rows after the header"
81
88
  end
@@ -194,11 +201,9 @@ module Calcsheet
194
201
  end
195
202
 
196
203
  def start(name)
197
- if @columns.nil? || @auto_width
198
- @writer.start_sheet(name, @columns, header: @header, widths: @widths, auto_width: @auto_width)
199
- else
200
- @writer.start_sheet(name, @columns, header: @header)
201
- end
204
+ options = @columns.nil? || @auto_width ? { widths: @widths, auto_width: @auto_width } : {}
205
+ options[:header_style] = @header_style if @header_style
206
+ @writer.start_sheet(name, @columns, header: @header, **options)
202
207
  @name = name
203
208
  @rows = @header ? 1 : 0
204
209
  @merges = []
@@ -269,7 +274,7 @@ module Calcsheet
269
274
  end
270
275
 
271
276
  def cell(value, index)
272
- return value if value.is_a?(Formula)
277
+ return formula(value, index) if value.is_a?(Formula)
273
278
 
274
279
  type = @types ? @types[index] : free_type(value, index)
275
280
  case type
@@ -293,6 +298,15 @@ module Calcsheet
293
298
  raise failure("expected #{EXPECTED.fetch(type)} (:#{type} column), got #{Untrusted.show(value)}", index)
294
299
  end
295
300
 
301
+ # A formula whose stored result the readers would refuse, as a cell value, raises.
302
+ def formula(value, index)
303
+ case (result = value.value)
304
+ when Integer, BigDecimal then number(result, index)
305
+ when String then long_text(result, index) unless result.bytesize < @short_text
306
+ end
307
+ value
308
+ end
309
+
296
310
  # The type of a value of a free sheet: the type #cell checks it as.
297
311
  def free_type(value, index)
298
312
  case value
@@ -310,9 +324,17 @@ module Calcsheet
310
324
  end
311
325
  end
312
326
 
313
- # The Styles of a row by column index, nil without any.
327
+ # The Styles of a row by column index, nil without any: the row's, and the columns' style where the row gives
328
+ # none.
314
329
  # @raise [ArgumentError] an unknown name or another value
315
330
  def styles_of(style, size)
331
+ styles = row_styles(style, size)
332
+ return styles || @column_styles if styles.nil? || @column_styles.nil?
333
+
334
+ Array.new(@column_styles.size) { |index| styles[index] || @column_styles[index] }
335
+ end
336
+
337
+ def row_styles(style, size)
316
338
  case style
317
339
  when nil then nil
318
340
  when Symbol then Array.new(@columns ? @columns.size : size, lookup_style(style))
@@ -364,32 +386,52 @@ module Calcsheet
364
386
  end
365
387
  end
366
388
 
389
+ # The headers are bold, or in the header style.
367
390
  def measure_headers
368
- @columns.each_with_index { |column, index| widen(index, column.header.length * 1.1) }
391
+ factor = @header_style ? font_factor(@header_style) : 1.1
392
+ @columns.each_with_index { |column, index| widen(index, column.header.length * factor) }
393
+ end
394
+
395
+ # How much wider than the default font a style's text is, roughly.
396
+ def font_factor(style)
397
+ (style.size || DEFAULT_SIZE).fdiv(DEFAULT_SIZE) * (style.bold ? 1.1 : 1)
369
398
  end
370
399
 
371
400
  # Widens the columns to the characters of the values, scaled by their style's font: a rough measure, as Excel's
372
- # own autofit is, of the text the cell shows. Formulas are not measured: their result is not known, nor the first
373
- # cell of a merge across columns, whose text spans them.
401
+ # own autofit is, of the text the cell shows. A formula counts by its stored result, if any; the first cell of a
402
+ # merge across columns, whose text spans them, is not measured.
374
403
  def measure(values, styles)
375
404
  row = @rows + 1
376
405
  spanning = @merges.select { _1.first_row == row && _1.last_column > _1.first_column }.map(&:first_column)
377
406
  values.each_with_index do |value, index|
378
407
  next if spanning.include?(index)
379
408
 
380
- characters =
381
- case value
382
- when nil, Formula then next
383
- when String then value.each_line.map { _1.chomp.length }.max || 0
384
- when Integer then value.to_s.length
385
- when BigDecimal then DecimalText.plain(value).length
386
- when Time then DATETIME_CHARACTERS
387
- when Date then DATE_CHARACTERS
388
- else BOOLEAN_CHARACTERS
389
- end
409
+ value = value.value if value.is_a?(Formula)
410
+ next if value.nil?
411
+
390
412
  style = styles&.[](index)
391
- factor = (style&.size || DEFAULT_SIZE).fdiv(DEFAULT_SIZE) * (style&.bold ? 1.1 : 1)
392
- widen(index, characters * factor)
413
+ widen(index, characters(value, index, style) * (style ? font_factor(style) : 1))
414
+ end
415
+ end
416
+
417
+ # The characters a value shows, as #measure counts them: a number as its style's number format, or its column's
418
+ # +scale:+, shows it.
419
+ def characters(value, index, style)
420
+ case value
421
+ when String then value.each_line.map { _1.chomp.length }.max || 0
422
+ when Integer, BigDecimal
423
+ format = style&.format
424
+ if format && %i[number percent].include?(format.kind)
425
+ shown_number(value, format.decimals, grouping: format.grouping,
426
+ suffix: format.kind == :percent ? format.code[/ ?%\z/].length : 0)
427
+ elsif (scale = @columns&.[](index)&.scale)
428
+ shown_number(value, scale)
429
+ else
430
+ value.is_a?(Integer) ? value.to_s.length : DecimalText.plain(value).length
431
+ end
432
+ when Time then DATETIME_CHARACTERS
433
+ when Date then DATE_CHARACTERS
434
+ else BOOLEAN_CHARACTERS
393
435
  end
394
436
  end
395
437
 
@@ -398,6 +440,16 @@ module Calcsheet
398
440
  @measured[index] = width if @measured[index].nil? || @measured[index] < width
399
441
  end
400
442
 
443
+ # The characters of a number shown with +decimals+ decimals, its thousands grouped or not, before +suffix+
444
+ # characters (a percentage's " %", whose number is the value times 100).
445
+ def shown_number(value, decimals, grouping: false, suffix: 0)
446
+ value *= 100 if suffix.positive?
447
+ rounded = BigDecimal(value).round(decimals)
448
+ digits = rounded.abs.to_i.to_s.length
449
+ (rounded.negative? ? 1 : 0) + digits + (grouping ? (digits - 1) / 3 : 0) + (decimals.positive? ? decimals + 1 : 0) +
450
+ suffix
451
+ end
452
+
401
453
  # A number whose written text (the full digits of an Integer, the plain text of a BigDecimal) is longer than
402
454
  # Cast::MAX_NUMBER_BYTES would make the file unreadable: the readers refuse longer numbers. The length is
403
455
  # computed without the text, which for BigDecimal("1e500000000") would take gigabytes. An Integer comes here only
@@ -69,7 +69,7 @@ module Calcsheet
69
69
 
70
70
  # Writes the BOM, if any, and the header row.
71
71
  # @raise [WriteError] for a second sheet
72
- def start_sheet(name, columns, header:, widths: nil, auto_width: false) # rubocop:disable Lint/UnusedMethodArgument
72
+ def start_sheet(name, columns, header:, widths: nil, auto_width: false, header_style: nil) # rubocop:disable Lint/UnusedMethodArgument
73
73
  raise WriteError.new("a CSV file holds a single sheet: cannot start sheet '#{name}'", sheet: name) if @started
74
74
 
75
75
  @started = true
@@ -167,6 +167,8 @@ module Calcsheet
167
167
  @sheets = []
168
168
  @styles = {}
169
169
  @custom_styles = {}
170
+ # The table:style-name attribute of a cell by its Style (by identity, nil for none) and data style.
171
+ @cell_attributes = {}.compare_by_identity
170
172
  @data_styles = {}
171
173
  @deferred = nil
172
174
  end
@@ -174,7 +176,7 @@ module Calcsheet
174
176
  # Starts the sheet's table in content.xml, with its columns and header row; with +auto_width+, the rows go to a
175
177
  # temporary file and the table is written when the sheet ends, its column widths known.
176
178
  # @raise [ArgumentError] for more columns than ODS holds
177
- def start_sheet(name, columns, header:, widths: nil, auto_width: false)
179
+ def start_sheet(name, columns, header:, widths: nil, auto_width: false, header_style: nil)
178
180
  check_columns(name, columns, "ODS") if columns
179
181
  @sheets << [ name, header ]
180
182
  @name = name
@@ -183,13 +185,14 @@ module Calcsheet
183
185
  @row_number = 0
184
186
  @merges = []
185
187
  prepare_cells(columns) if columns
188
+ @value_styles = columns&.map { |column| (name = cell_style(column)) && "N#{name}".freeze }
186
189
  if auto_width
187
190
  flush
188
191
  defer_rows
189
192
  else
190
193
  @buffer << table_start(columns ? columns.map(&:width) : widths || [])
191
194
  end
192
- write_header(columns) if header
195
+ write_header(columns, header_style) if header
193
196
  end
194
197
 
195
198
  # @param range [CellRange] spanned by its first cell when its first row is written
@@ -331,10 +334,16 @@ module Calcsheet
331
334
  count == 1 ? EMPTY_CELL : %(<table:table-cell table:number-columns-repeated="#{count}"/>)
332
335
  end
333
336
 
334
- def write_header(columns)
335
- use_style(HEADER_STYLE)
337
+ # The header row, bold or in +header_style+ (a Style).
338
+ def write_header(columns, header_style)
339
+ if header_style
340
+ start = %(<table:table-cell#{style_attribute(header_style, nil)} office:value-type="string")
341
+ else
342
+ use_style(HEADER_STYLE)
343
+ start = HEADER_START
344
+ end
336
345
  @buffer << "<table:table-row>"
337
- columns.each { text_cell(HEADER_START, _1.header) }
346
+ columns.each { text_cell(start, _1.header) }
338
347
  @buffer << "</table:table-row>"
339
348
  @row_number = 1
340
349
  end
@@ -410,14 +419,21 @@ module Calcsheet
410
419
  attributes
411
420
  end
412
421
 
422
+ # A formula's stored result, if any, is written as a value of its class's type after the formula attribute, with
423
+ # the data style of a free sheet's value of that type.
413
424
  def any_cell(value, index, style, spans)
414
425
  type = value.nil? ? nil : type_of(value, index)
415
- attributes = style_attribute(style, type && value_style(type, index)) + spans
426
+ if type == :formula
427
+ formula = XmlText.escape(OpenFormula.from_excel(value.text)).gsub(ATTRIBUTE_BREAKS, ATTRIBUTE_SPACE)
428
+ spans += %( table:formula="#{formula}")
429
+ value = value.value
430
+ type = value.nil? ? nil : Formula::RESULT_TYPES.fetch(value.class)
431
+ attributes = style_attribute(style, type && free_value_style(type)) + spans
432
+ else
433
+ attributes = style_attribute(style, type && value_style(type, index)) + spans
434
+ end
416
435
  case type
417
436
  when nil then @buffer << "<table:table-cell#{attributes}/>"
418
- when :formula
419
- formula = XmlText.escape(OpenFormula.from_excel(value.text)).gsub(ATTRIBUTE_BREAKS, ATTRIBUTE_SPACE)
420
- @buffer << "<table:table-cell#{attributes} table:formula=\"#{formula}\"/>"
421
437
  when :string then text_cell(%(<table:table-cell#{attributes} office:value-type="string"), value)
422
438
  when :integer, :decimal
423
439
  text = type == :integer ? value.to_s : DecimalText.plain(value)
@@ -459,21 +475,25 @@ module Calcsheet
459
475
  # The name of the data style a cell of +type+ takes without a style that gives one: its column's, or a date's,
460
476
  # time's or boolean's.
461
477
  def value_style(type, index)
462
- if @columns && type != :formula
463
- name = cell_style(@columns[index])
464
- return name && "N#{name}"
465
- end
478
+ @columns ? @value_styles[index] : free_value_style(type)
479
+ end
466
480
 
481
+ def free_value_style(type)
467
482
  case type
468
- when :date then use_style(DATE_STYLE) && "N#{DATE_STYLE}"
469
- when :datetime then use_style(DATETIME_STYLE) && "N#{DATETIME_STYLE}"
470
- when :boolean then use_style(BOOLEAN_STYLE) && "N#{BOOLEAN_STYLE}"
483
+ when :date then use_style(DATE_STYLE) && "NDate"
484
+ when :datetime then use_style(DATETIME_STYLE) && "NDatetime"
485
+ when :boolean then use_style(BOOLEAN_STYLE) && "NBoolean"
471
486
  end
472
487
  end
473
488
 
474
489
  # The table:style-name attribute of a cell: a common style for its Style with its data style (the Style's
475
490
  # format, else +value_style+), the plain data style's cell style without a Style, nothing without either.
476
491
  def style_attribute(style, data_style)
492
+ by_data_style = @cell_attributes[style] ||= {}
493
+ by_data_style.fetch(data_style) { by_data_style[data_style] = new_style_attribute(style, data_style).freeze }
494
+ end
495
+
496
+ def new_style_attribute(style, data_style)
477
497
  if style.nil?
478
498
  return "" if data_style.nil?
479
499
 
@@ -72,6 +72,8 @@ module Calcsheet
72
72
  @fills = { nil => 0, gray125: 1 }
73
73
  @borders = { nil => 0 }
74
74
  @letters_cache = {}
75
+ # The s attribute of a cell by its Style (by identity, nil for none) and number format code: one lookup per cell.
76
+ @cell_attributes = {}.compare_by_identity
75
77
  @formulas = false
76
78
  @buffer = new_buffer
77
79
  @entry = nil
@@ -82,7 +84,7 @@ module Calcsheet
82
84
  # go to a temporary file and the entry is written when the sheet ends, its widths known.
83
85
  # @raise [ArgumentError] for more columns than XLSX holds
84
86
  # @raise [WriteError] for a header longer than a cell holds
85
- def start_sheet(name, columns, header:, widths: nil, auto_width: false)
87
+ def start_sheet(name, columns, header:, widths: nil, auto_width: false, header_style: nil)
86
88
  check_sheet(name, columns) if columns
87
89
  @sheet_names << name
88
90
  @name = name
@@ -92,12 +94,13 @@ module Calcsheet
92
94
  @row_number = 0
93
95
  @merges = []
94
96
  prepare_cells(columns) if columns
97
+ @column_formats = columns&.map { format_of(_1) }
95
98
  if auto_width
96
99
  defer_rows
97
100
  else
98
101
  open_sheet_entry(cols(columns ? columns.map(&:width) : widths || []))
99
102
  end
100
- write_header(columns) if header
103
+ write_header(columns, header_style) if header
101
104
  end
102
105
 
103
106
  # Writes one row of checked values (see the contract in WriteBook): a typed row without styles or formulas from
@@ -287,7 +290,7 @@ module Calcsheet
287
290
  value = values[index]
288
291
  style = styles&.[](index)
289
292
  if value.nil?
290
- @buffer << '<c r="' << letter(index) << number << '" s="' << xf(style, nil).to_s << '"/>' if style
293
+ @buffer << '<c r="' << letter(index) << number << '"' << cell_attribute(style, nil) << "/>" if style
291
294
  else
292
295
  any_cell(value, index, number, style)
293
296
  end
@@ -304,12 +307,9 @@ module Calcsheet
304
307
  def any_cell(value, index, number, style)
305
308
  type = type_of(value, index)
306
309
  code = style&.format&.code || default_format(type, index)
307
- @buffer << '<c r="' << letter(index) << number << '"'
308
- @buffer << ' s="' << xf(style, code).to_s << '"' if style || code
310
+ @buffer << '<c r="' << letter(index) << number << '"' << cell_attribute(style, code)
309
311
  case type
310
- when :formula
311
- @formulas = true
312
- @buffer << "><f>" << XmlText.escape(value.text) << "</f></c>"
312
+ when :formula then formula_cell(value, index)
313
313
  when :string
314
314
  @buffer << ' t="inlineStr"><is><t xml:space="preserve">'
315
315
  value_text(value, :string, index)
@@ -322,6 +322,23 @@ module Calcsheet
322
322
  end
323
323
  end
324
324
 
325
+ # A formula with its stored result, if any, after the cell's style: t="str" for a text, t="b" for a boolean.
326
+ def formula_cell(formula, index)
327
+ @formulas = true
328
+ result = formula.value
329
+ case result
330
+ when String then @buffer << ' t="str"'
331
+ when true, false then @buffer << ' t="b"'
332
+ end
333
+ @buffer << "><f>" << XmlText.escape(formula.text) << "</f>"
334
+ unless result.nil?
335
+ @buffer << "<v>"
336
+ value_text(result, Formula::RESULT_TYPES.fetch(result.class), index)
337
+ @buffer << "</v>"
338
+ end
339
+ @buffer << "</c>"
340
+ end
341
+
325
342
  # The type a value is written as: a formula, its column's type, or in a free sheet its class's.
326
343
  def type_of(value, index)
327
344
  return :formula if value.is_a?(Formula)
@@ -339,7 +356,7 @@ module Calcsheet
339
356
 
340
357
  # The number format of a cell without a style that gives one: its column's, or a date's or time's.
341
358
  def default_format(type, index)
342
- return format_of(@columns[index]) if @columns && type != :formula
359
+ return @column_formats[index] if @columns && type != :formula
343
360
 
344
361
  case type
345
362
  when :date then DATE_FORMAT
@@ -357,6 +374,14 @@ module Calcsheet
357
374
 
358
375
  # The cellXfs index of a Style with a number format code (either may be nil), registered at its first use; a
359
376
  # Style that sets only the number format shares the index of that format.
377
+ # The s attribute of a cell with a Style and a number format code (either may be nil, then "" for both).
378
+ def cell_attribute(style, code)
379
+ by_code = @cell_attributes[style] ||= {}
380
+ by_code.fetch(code) do
381
+ by_code[code] = style.nil? && code.nil? ? "" : %( s="#{xf(style, code)}").freeze
382
+ end
383
+ end
384
+
360
385
  def xf(style, code)
361
386
  return style(code) if style.nil? || style.plain?
362
387
 
@@ -387,8 +412,9 @@ module Calcsheet
387
412
  attributes.empty? ? nil : "<alignment #{attributes.join(" ")}/>"
388
413
  end
389
414
 
390
- def write_header(columns)
391
- style = style_attribute(HEADER_STYLE)
415
+ # The header row, bold or in +header_style+ (a Style).
416
+ def write_header(columns, header_style)
417
+ style = header_style ? cell_attribute(header_style, nil) : style_attribute(HEADER_STYLE)
392
418
  @buffer << '<row r="1">'
393
419
  columns.each_with_index do |column, index|
394
420
  @buffer << %(<c r="#{@letters[index]}1"#{style} t="inlineStr"><is><t xml:space="preserve">)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calcsheet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Demetra Opinioni.net Srl