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 +4 -4
- data/CHANGELOG.md +18 -1
- data/README.md +26 -13
- data/lib/calcsheet/column.rb +10 -4
- data/lib/calcsheet/formula.rb +39 -6
- data/lib/calcsheet/version.rb +1 -1
- data/lib/calcsheet/write_book.rb +11 -8
- data/lib/calcsheet/write_sheet.rb +75 -23
- data/lib/calcsheet/writers/csv_writer.rb +1 -1
- data/lib/calcsheet/writers/ods_writer.rb +36 -16
- data/lib/calcsheet/writers/xlsx_writer.rb +37 -11
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cb9f770dac27dff3dcc72ce14a9c5183d2654f9e8ded440f6b62c9b90b970ad
|
|
4
|
+
data.tar.gz: b6c5a2c6488c6ebf0ce1c1efd486ae352f17fac6448dee01f94ba4cbb26d20fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
|
368
|
-
sheet
|
|
369
|
-
sheet
|
|
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",
|
|
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).
|
|
387
|
-
|
|
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)`.
|
|
395
|
-
|
|
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)
|
|
401
|
-
|
|
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.
|
|
921
|
+
gem "calcsheet", "~> 0.3"
|
|
909
922
|
gem "calcsheet-native"
|
|
910
923
|
```
|
|
911
924
|
|
data/lib/calcsheet/column.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/calcsheet/formula.rb
CHANGED
|
@@ -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)".
|
|
9
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/calcsheet/version.rb
CHANGED
data/lib/calcsheet/write_book.rb
CHANGED
|
@@ -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
|
|
28
|
-
# +name+ is valid and unique, +columns+ an Array of frozen Column, or nil for a free sheet
|
|
29
|
-
# cell has the type of its value, see #row), whose column widths +widths+ gives (an Array of
|
|
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
|
|
32
|
-
# rows: the backend keeps the rows (in a temporary file)
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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.
|
|
373
|
-
#
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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) && "
|
|
469
|
-
when :datetime then use_style(DATETIME_STYLE) && "
|
|
470
|
-
when :boolean then use_style(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 << '"
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
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">)
|