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
@@ -6,6 +6,7 @@ require_relative "../../configuration"
6
6
  require_relative "../../errors"
7
7
  require_relative "../../grid"
8
8
  require_relative "../../odf"
9
+ require_relative "../../row_builder"
9
10
  require_relative "../../untrusted"
10
11
  require_relative "../references"
11
12
  require_relative "../sax_sheet"
@@ -15,8 +16,9 @@ module Calcsheet
15
16
  module Readers
16
17
  module Ods
17
18
  # Streams one table of an ODS +content.xml+ with Ox's SAX parser and hands each non-blank row to a block as a
18
- # RawRow, cast with a Cast. The stream starts at the table start tag or at a row start tag inside
19
- # it (see Scanner#io_at_sheet); parsing stops at the end of the table.
19
+ # RawRow, built by a RowBuilder: the parser places and checks each cell, the builder casts it. The stream starts
20
+ # at the table start tag or at a row start tag inside it (see Scanner#io_at_sheet); parsing stops at the end of
21
+ # the table.
20
22
  #
21
23
  # Rows are numbered by counting +table-row+ elements with their +number-rows-repeated+, cells placed by
22
24
  # counting +table-cell+ and +covered-table-cell+ elements with their +number-columns-repeated+.
@@ -69,11 +71,12 @@ module Calcsheet
69
71
  COLLAPSIBLE = /[\t\n\r]| {2}/
70
72
  # The byte of a space.
71
73
  SPACE_BYTE = 32
72
- # The kind of value of each office:value-type; another type is read as text.
73
- KINDS = { "float" => :number, "percentage" => :number, "currency" => :number, "date" => :date, "time" => :time,
74
- "boolean" => :boolean, "string" => :string }.freeze
74
+ # The kind of value of each office:value-type, a RowBuilder kind but for :string (a string whose value may be
75
+ # its +office:string-value+); another type is read as text.
76
+ KINDS = { "float" => :number, "percentage" => :number, "currency" => :number, "date" => :iso_date,
77
+ "time" => :duration, "boolean" => :boolean, "string" => :string }.freeze
75
78
  # The reason of a typed cell without its value attribute.
76
- MISSING = { date: :invalid_date, boolean: :invalid_boolean }.freeze
79
+ MISSING = { iso_date: :invalid_date, boolean: :invalid_boolean }.freeze
77
80
 
78
81
  # Element codes, the values of +@elements+: a Hash lookup and an Integer +case+ per callback.
79
82
  TABLE = 1
@@ -101,7 +104,8 @@ module Calcsheet
101
104
  # The +formats:+ of a document without cell formats.
102
105
  NO_FORMATS = {}.freeze
103
106
  # The attribute that holds the value of each kind.
104
- VALUE_OF = { number: VALUE, date: DATE_VALUE, time: TIME_VALUE, boolean: BOOLEAN_VALUE, string: STRING_VALUE }.freeze
107
+ VALUE_OF = { number: VALUE, iso_date: DATE_VALUE, duration: TIME_VALUE, boolean: BOOLEAN_VALUE,
108
+ string: STRING_VALUE }.freeze
105
109
 
106
110
  # Parses +io+ and yields the rows numbered +start_row+ or later.
107
111
  #
@@ -129,8 +133,8 @@ module Calcsheet
129
133
  #
130
134
  # The state of the parse, in instance variables so that a callback allocates nothing:
131
135
  #
132
- # - set once: +@sheet+, +@cast+, +@types+, +@formats+ (nil when empty), +@start_row+, +@on_row+,
133
- # +@max_cell_bytes+, and from #name_codes +@elements+, +@attributes+, +@text_qualifier+,
136
+ # - set once: +@sheet+, +@builder+ (the RowBuilder of the rows), +@formats+ (nil when empty), +@start_row+,
137
+ # +@on_row+, +@max_cell_bytes+, and from #name_codes +@elements+, +@attributes+, +@text_qualifier+,
134
138
  # +@rows_repeated_name+ and +@columns_repeated_name+;
135
139
  # - across rows: +@next_row+ (the number of the next row), +@columns+ (the columns of the table-column elements
136
140
  # so far) and +@column_formats+ (+[first, last, format]+ of those whose default cell style has a format),
@@ -138,8 +142,8 @@ module Calcsheet
138
142
  # - the element whose attributes come: +@element+ (an element code, nil inside a cell but for text:s, text:tab
139
143
  # and text:line-break);
140
144
  # - per table-column: +@column_repeated+, +@column_style+;
141
- # - per row: +@in_row+, +@row_number+, +@rows_repeated+, +@row_style+, +@values+ (sparse, see
142
- # SaxSheet#trim_row!), +@errors+, +@last+ and +@column+ (the column of the next cell);
145
+ # - per row: +@in_row+, +@row_number+, +@rows_repeated+, +@row_style+ and +@column+ (the column of the next
146
+ # cell); its cells are in +@builder+;
143
147
  # - per cell: +@in_cell+, +@covered+, +@depth+ (below the cell element), +@columns_repeated+, +@kind+ (from the
144
148
  # value type), +@value+ and +@value_code+ (the value attribute and which one), +@cell_style+, +@error+
145
149
  # (+calcext:value-type="error"+), +@missing+ (the reason of a typed cell without its value), +@text+,
@@ -150,8 +154,7 @@ module Calcsheet
150
154
  super()
151
155
  @max_cell_bytes = max_cell_bytes
152
156
  @sheet = sheet
153
- @cast = cast
154
- @types = types
157
+ @builder = RowBuilder.new(cast, types, number_errors: :raw)
155
158
  @formats = formats.empty? ? nil : formats
156
159
  # [first column, last column, format] of the columns whose default cell style has a format.
157
160
  @column_formats = []
@@ -340,31 +343,33 @@ module Calcsheet
340
343
  # --- rows -----------------------------------------------------------------------------
341
344
 
342
345
  def begin_row
346
+ # A row nested in another one starts empty: the cells of the outer row before it are dropped.
347
+ @builder.finish(0) if @in_row
343
348
  @in_row = true
344
349
  @row_number = @next_row
345
350
  @row_style = nil
346
351
  @rows_repeated = 1
347
- @values = []
348
- @errors = nil
349
- @last = -1
350
352
  @column = 0
351
353
  end
352
354
 
355
+ # Builds the row even before +start_row+, so that the builder starts the next one empty. The builder's row is
356
+ # the last of a repeated run; every row before it gets copies of its arrays.
353
357
  def finish_row
354
358
  @in_row = false
355
359
  number = @row_number
356
360
  last_number = number + @rows_repeated - 1
357
361
  @next_row = last_number + 1
358
- return if @last.negative? || last_number < @start_row
362
+ row = @builder.finish(last_number)
363
+ return if row.nil? || last_number < @start_row
359
364
  raise malformed("the row is beyond the last row #{Grid::MAX_ROWS}", row: number) if last_number > Grid::MAX_ROWS
360
365
 
361
- trim_row!
362
- [ number, @start_row ].max.upto(last_number) { |row| emit(row, last: row == last_number) }
366
+ [ number, @start_row ].max.upto(last_number - 1) { |each| yield_row(copy(row, each)) }
367
+ yield_row(row)
363
368
  end
364
369
 
365
- # Yields the row numbered +number+; every row of a repeated run but the last gets copies of the arrays.
366
- def emit(number, last:)
367
- yield_row(number, last ? @values : @values.dup, last || @errors.nil? ? @errors : @errors.dup)
370
+ # @return [RawRow] +row+ numbered +number+, with copies of its arrays
371
+ def copy(row, number)
372
+ RawRow.new(number: number, values: row.values.dup, errors: row.errors&.dup)
368
373
  end
369
374
 
370
375
  # --- cells ----------------------------------------------------------------------------
@@ -467,6 +472,9 @@ module Calcsheet
467
472
  malformed("the text is longer than max_cell_bytes (#{@max_cell_bytes} bytes)", column_index: @column)
468
473
  end
469
474
 
475
+ # Hands the cell to the builder as the kind of its value type, after the checks that are the parser's: its
476
+ # column, the length and the UTF-8 of its value. A repeated cell goes into each of its columns, cast once per
477
+ # declared type and format; only a number and a date ask for the formats of their columns.
470
478
  def finish_cell
471
479
  @in_cell = false
472
480
  column = @column
@@ -477,45 +485,19 @@ module Calcsheet
477
485
 
478
486
  raise malformed("a cell is beyond the last column #{Grid::LAST_COLUMN}") if @column > Grid::MAX_COLUMNS
479
487
 
488
+ kind = @kind
480
489
  if repeat == 1
481
- begin
482
- @values[column] = cast(raw, @types && @types[column], format_of(column))
483
- rescue Cast::Failure => failure
484
- (@errors ||= []) << [ column, raw, failure.reason ]
485
- end
490
+ @builder.add(column, kind, raw, format_of(column), missing: @missing)
491
+ elsif kind == :number || kind == :iso_date
492
+ @builder.add_run(column, @column - 1, kind, raw, missing: @missing) { |index| format_of(index) }
486
493
  else
487
- place(raw, column)
488
- end
489
- @last = @column - 1 if (@kind != :text || !blank?(raw)) && @column - 1 > @last
490
- end
491
-
492
- # Casts the value of a repeated cell into each of its columns, once per declared type and format.
493
- def place(raw, column)
494
- type = format = value = failure = nil
495
- column.upto(@column - 1) do |index|
496
- wanted = @types && @types[index]
497
- styled = format_of(index)
498
- # Identity, not equality: the formats are the frozen Arrays of the +formats+ Hash, so the same style gives
499
- # the same object, and a check by identity costs nothing per column. An equal format of another style only
500
- # casts once more.
501
- if index == column || wanted != type || !styled.equal?(format)
502
- type = wanted
503
- format = styled
504
- begin
505
- failure = nil
506
- value = cast(raw, wanted, styled)
507
- rescue Cast::Failure => error
508
- failure = error
509
- end
510
- end
511
- if failure then (@errors ||= []) << [ index, raw, failure.reason ]
512
- else @values[index] = value
513
- end
494
+ @builder.add_run(column, @column - 1, kind, raw, missing: @missing)
514
495
  end
515
496
  end
516
497
 
517
- # The raw value of the cell; leaves its kind in +@kind+: :text (strings), :number, :date, :time, :boolean,
518
- # :error, or :missing for a typed cell without its value attribute.
498
+ # The raw value of the cell; leaves its RowBuilder kind in +@kind+: :text (strings), :number, :iso_date,
499
+ # :duration, :boolean, :error, or :missing for a typed cell without its value attribute, its reason in
500
+ # +@missing+.
519
501
  # @return [String, nil] nil for an empty cell
520
502
  def raw_value
521
503
  kind = @kind
@@ -546,20 +528,18 @@ module Calcsheet
546
528
  raise malformed("the value is longer than #{Cast::MAX_NUMBER_BYTES} bytes", column_index: @column)
547
529
  end
548
530
 
549
- # The format only matters to numbers and dates.
531
+ # The format of the cell at +column+ as the builder takes it; only numbers and dates have one. The value of a
532
+ # number is exact, so its style only pads it: a number under a zero-padding style takes the style's
533
+ # +[:general, pad]+, one under a date style none (a date format would read the number as a date serial). A
534
+ # date takes its style's format, of which the builder reads only the kind. A format is the frozen Array of
535
+ # the style or nil, which RowBuilder#add_run compares by identity.
550
536
  def format_of(column)
551
- format_at(column) if @kind == :number || @kind == :date
552
- end
553
-
554
- def cast(raw, type, format)
555
- case @kind
556
- when :text then @cast.text(raw, type)
557
- when :number then @cast.number(raw, :general, type, pad: format && format[1])
558
- when :date then @cast.iso_date(raw, type, format && format[0])
559
- when :time then @cast.duration(raw, type)
560
- when :boolean then @cast.boolean(boolean(raw), type)
561
- when :error then raise Cast::Failure.new(:cell_error, raw)
562
- else raise Cast::Failure.new(@missing, raw)
537
+ kind = @kind
538
+ if kind == :number
539
+ format = format_at(column)
540
+ format if format && format[0] == :general
541
+ elsif kind == :iso_date
542
+ format_at(column)
563
543
  end
564
544
  end
565
545
 
@@ -93,7 +93,9 @@ module Calcsheet
93
93
  @hidden_styles = nil
94
94
  @head_formats = nil
95
95
  @cell_formats = nil
96
+ # Every table, once a scan reached the end of the entry; the tables met so far by #each_sheet.
96
97
  @sheets = nil
98
+ @known_sheets = nil
97
99
  end
98
100
 
99
101
  # @return [Hash{Symbol => String}] the prefix of each namespace of NAMESPACES ("" for the default namespace);
@@ -125,17 +127,42 @@ module Calcsheet
125
127
  # @raise [MalformedFileError] for a damaged document, a sheet name that is not UTF-8 or a bound exceeded
126
128
  # @raise [TooManySheetsError] for more tables than +max_sheets+
127
129
  def sheets
128
- @sheets ||= walk do |cursor|
129
- list = []
130
+ each_sheet { nil } unless @sheets
131
+ @sheets
132
+ end
133
+
134
+ # Yields the tables in document order, scanning only as far as the caller reads: one that stops at the first
135
+ # table (the default sheet of a read) inflates the part of the entry before it, not the whole entry. The
136
+ # tables met are kept; asked for more, the scan starts again from the start and goes on past them. A damaged
137
+ # document, a bad name or more tables than +max_sheets+ raise only when the scan reaches them.
138
+ #
139
+ # @yieldparam sheet [SheetInfo]
140
+ # @return [Enumerator, nil] an Enumerator without a block
141
+ # @raise [MalformedFileError] for a damaged document, a sheet name that is not UTF-8 or a bound exceeded
142
+ # @raise [TooManySheetsError] for more tables than +max_sheets+
143
+ def each_sheet
144
+ return enum_for(:each_sheet) unless block_given?
145
+ return @sheets.each { yield _1 } if @sheets
146
+
147
+ known = (@known_sheets ||= [])
148
+ known.each { yield _1 }
149
+ walk do |cursor|
150
+ count = 0
130
151
  each_table(cursor) do |tag|
131
- if @max_sheets && list.size == @max_sheets
132
- raise TooManySheetsError.new(limit: :max_sheets, max: @max_sheets, actual: list.size + 1)
152
+ if @max_sheets && count == @max_sheets
153
+ raise TooManySheetsError.new(limit: :max_sheets, max: @max_sheets, actual: count + 1)
133
154
  end
134
155
 
135
- list << sheet_info(tag, list.size)
156
+ count += 1
157
+ next if count <= known.size
158
+
159
+ info = sheet_info(tag, count - 1)
160
+ known << info
161
+ yield info
136
162
  end
137
- list.freeze
163
+ @sheets = known.dup.freeze
138
164
  end
165
+ nil
139
166
  end
140
167
 
141
168
  # Inflates the entry from its start and stops at the table of the sheet, or at the row start tag holding
@@ -48,6 +48,15 @@ module Calcsheet
48
48
  @scanner.sheets
49
49
  end
50
50
 
51
+ # The sheets, scanned only as far as the caller reads (see Reading for the backend contract).
52
+ # @yieldparam sheet [SheetInfo]
53
+ # @return [Enumerator, nil] an Enumerator without a block
54
+ # @raise [MalformedFileError] for a damaged content.xml, when the scan reaches it
55
+ # @raise [TooManySheetsError] for more tables than +max_sheets+, when the scan reaches them
56
+ def each_sheet(&block)
57
+ @scanner.each_sheet(&block)
58
+ end
59
+
51
60
  # Holds nothing between reads: each read closes its own stream.
52
61
  def close
53
62
  nil
@@ -55,8 +64,14 @@ module Calcsheet
55
64
 
56
65
  private
57
66
 
67
+ # @return [SheetInfo] the sheet at +index+, scanning the tables up to it
68
+ # @raise [IndexError] when there is no such sheet
69
+ def sheet_at(index)
70
+ each_sheet.find { _1.index == index } or raise IndexError, "no sheet #{index}"
71
+ end
72
+
58
73
  def each_row(index, cast, types, start_row, &block)
59
- sheet = sheets.fetch(index)
74
+ sheet = sheet_at(index)
60
75
  io = @scanner.io_at_sheet(index, row: start_row) or return
61
76
  begin
62
77
  Ods::ContentParser.parse(io, sheet: sheet.name, prefixes: @scanner.prefixes, cast: cast, types: types,
@@ -1,22 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ox"
4
- require_relative "../cast"
5
4
  require_relative "../errors"
6
5
  require_relative "../limited_inflater"
7
6
  require_relative "../raw_row"
8
- require_relative "../reading"
9
7
  require_relative "ox_errors"
10
8
 
11
9
  module Calcsheet
12
10
  module Readers
13
11
  # What the SAX parsers of one sheet (Xlsx::SheetParser, Ods::ContentParser) share: parsing until they throw STOP,
14
- # the translation of parser errors, errors located at the sheet, row and column, and the end of a row.
12
+ # the translation of parser errors, errors located at the sheet, row and column, and the handing over of a row.
13
+ # Their cells become rows in a RowBuilder.
15
14
  #
16
15
  # The including class defines OPTIONS (for Ox.sax_parse), +current_row+ and +current_column+ (where an error is
17
- # located), and keeps +@sheet+ (the sheet name), +@on_row+ (the block rows are yielded to), +@values+, +@errors+
18
- # (+[column, raw, reason]+ per failed cell, or nil), +@last+ (the last non-blank column, -1 for none) and
19
- # +@in_cell+. +@yielding+ is true while the caller's block runs.
16
+ # located), and keeps +@sheet+ (the sheet name), +@on_row+ (the block rows are yielded to) and +@in_cell+.
17
+ # +@yielding+ is true while the caller's block runs.
20
18
  #
21
19
  # @api private
22
20
  module SaxSheet
@@ -46,54 +44,14 @@ module Calcsheet
46
44
 
47
45
  private
48
46
 
49
- # Drops what follows the last non-blank cell: the values (a failed cast leaves the last non-blank cell nil,
50
- # whitespace after it is trimmed) and, like CSV, the cast failures of the whitespace after it. +@errors+ is nil
51
- # when none is left, else sorted by column.
52
- #
53
- # +@values+ is sparse: a cell sets only its own position, so the Array is as long as the last cell that got a
54
- # value, nil before it where cells were empty or missing. A cell whose cast failed, or an error cell, sets
55
- # nothing: when it is the last non-blank one and no whitespace text after it got a value, the Array is shorter
56
- # than +@last+ + 1, and assigning nil at +@last+ extends it to that cell. Otherwise the values after +@last+
57
- # (whitespace texts) are popped.
58
- def trim_row!
59
- if @values.size <= @last
60
- @values[@last] = nil
61
- else
62
- @values.pop while @values.size > @last + 1
63
- end
64
- return unless @errors
65
-
66
- @errors.reject! { |error| error[0] > @last }
67
- if @errors.empty? then @errors = nil
68
- elsif @errors.size > 1 then @errors.sort_by!(&:first)
69
- end
70
- end
71
-
72
- # Hands a row to the caller's block; its errors are the block's own: +@yielding+ tells #parse not to translate
47
+ # Hands a RawRow to the caller's block; its errors are the block's own: +@yielding+ tells #parse not to translate
73
48
  # them.
74
- def yield_row(number, values, errors)
75
- row = RawRow.new(number: number, values: values, errors: errors)
49
+ def yield_row(row)
76
50
  @yielding = true
77
51
  @on_row.call(row)
78
52
  @yielding = false
79
53
  end
80
54
 
81
- # Whitespace only, without a regexp for the common text that starts with a printable ASCII character.
82
- def blank?(text)
83
- byte = text.getbyte(0)
84
- !(byte > 32 && byte < 128) && !Reading::NON_BLANK.match?(text)
85
- end
86
-
87
- # The texts of a boolean value in XLSX ("1", "0") and ODS ("true", "false"); both formats accept both. A case of
88
- # String literals, which Ruby dispatches by hash, costs less per cell than a Hash or Array lookup.
89
- def boolean(text)
90
- case text
91
- when "1", "true" then true
92
- when "0", "false" then false
93
- else raise Cast::Failure.new(:invalid_boolean, text)
94
- end
95
- end
96
-
97
55
  # The zip stream refused a text or a tag too long for max_cell_bytes: located like the parser's own errors.
98
56
  def token_too_long(error)
99
57
  malformed("a text or a tag is longer than max_cell_bytes (#{error.max_cell_bytes} bytes) allows",
@@ -5,6 +5,7 @@ require_relative "../../cast"
5
5
  require_relative "../../configuration"
6
6
  require_relative "../../errors"
7
7
  require_relative "../../grid"
8
+ require_relative "../../row_builder"
8
9
  require_relative "../../untrusted"
9
10
  require_relative "../sax_sheet"
10
11
  require_relative "escapes"
@@ -13,9 +14,9 @@ require_relative "../references"
13
14
  module Calcsheet
14
15
  module Readers
15
16
  module Xlsx
16
- # Streams the XML of one sheet with Ox's SAX parser and hands each non-blank row to a block as a RawRow, cast
17
- # with a Cast. Parsing stops at the end of +sheetData+, so what follows it (merged ranges,
18
- # hyperlinks...) is never read.
17
+ # Streams the XML of one sheet with Ox's SAX parser and hands each non-blank row to a block as a RawRow, built
18
+ # by a RowBuilder: the parser places and checks each cell, the builder casts it. Parsing stops at the end of
19
+ # +sheetData+, so what follows it (merged ranges, hyperlinks...) is never read.
19
20
  #
20
21
  # Rows are numbered by their +r+ attribute, counted on from the previous row when it is absent; cells are
21
22
  # placed by the column letters of their +r+, after the previous cell when it is absent. Element names
@@ -80,15 +81,14 @@ module Calcsheet
80
81
  #
81
82
  # The state of the parse, in instance variables so that a callback allocates nothing:
82
83
  #
83
- # - set once: +@sheet+, +@cast+, +@types+, +@styles+ (the WorkbookMeta), +@strings+ (the lambda that loads the
84
- # shared strings) and +@table+ (the SharedStrings, loaded at the first +t="s"+ cell), +@start_row+,
85
- # +@on_row+ (the caller's block), +@max_cell_bytes+, +@style_kinds+ (the memo of #style_kind);
84
+ # - set once: +@sheet+, +@builder+ (the RowBuilder of the rows), +@styles+ (the WorkbookMeta), +@strings+ (the
85
+ # lambda that loads the shared strings) and +@table+ (the SharedStrings, loaded at the first +t="s"+ cell),
86
+ # +@start_row+, +@on_row+ (the caller's block), +@max_cell_bytes+, +@style_kinds+ (the memo of #style_kind);
86
87
  # - across rows: +@last_number+ (the number of the last row read, 0 before the first) and +@yielding+ (the
87
88
  # caller's block runs; see SaxSheet#parse);
88
89
  # - the element whose attributes come: +@element+;
89
- # - per row (#begin_row): +@in_row+, +@row_number+ (its +r+, nil without one), +@values+ (sparse, see
90
- # SaxSheet#trim_row!), +@errors+, +@last+ (the last non-blank column, -1 for none) and +@next_column+ (the
91
- # column of a cell without +r+);
90
+ # - per row (#begin_row): +@in_row+, +@row_number+ (its +r+, nil without one) and +@next_column+ (the column
91
+ # of a cell without +r+); its cells are in +@builder+;
92
92
  # - per cell (+c+): +@in_cell+ (inside a row), +@column+ (from its +r+, nil without one), +@cell_type+ (its
93
93
  # +t+), +@style+ (its +s+) and +@text+ (the text joined so far, nil before any);
94
94
  # - per text: +@capture+ (the text of +v+, or of a +t+ of the inline string outside phonetic runs, is the
@@ -101,8 +101,9 @@ module Calcsheet
101
101
  @text_limit = nil
102
102
  @bounded = false
103
103
  @sheet = sheet
104
- @cast = cast
105
- @types = types
104
+ # A number cell that fails an :integer type reports the number as read (Cast#integer_number), not the full
105
+ # print of a double that Excel writes.
106
+ @builder = RowBuilder.new(cast, types, number_errors: :as_read)
106
107
  @styles = styles
107
108
  @strings = strings
108
109
  @table = nil
@@ -157,7 +158,7 @@ module Calcsheet
157
158
  when :c
158
159
  finish_cell if @in_cell
159
160
  # The text ends with its cell, also for a cell nested in an inline string: no later text is joined to
160
- # it, and @text (a shared string, or the raw value of a cast error) is never changed afterwards.
161
+ # it, and @text (the raw value of a cast error) is never changed afterwards.
161
162
  @text = nil
162
163
  @capture = false
163
164
  @inline = false
@@ -237,24 +238,26 @@ module Calcsheet
237
238
  end
238
239
 
239
240
  def begin_row
241
+ # A row nested in another one starts empty: the cells of the outer row before it are dropped.
242
+ @builder.finish(0) if @in_row
240
243
  @in_row = true
241
244
  @row_number = nil
242
- @values = []
243
- @errors = nil
244
- @last = -1
245
245
  @next_column = 0
246
246
  end
247
247
 
248
+ # Builds the row even before +start_row+, so that the builder starts the next one empty.
248
249
  def finish_row
249
250
  @in_row = false
250
251
  number = @row_number || counted_row
251
252
  @last_number = number
252
- return if @last.negative? || number < @start_row
253
-
254
- trim_row!
255
- yield_row(number, @values, @errors)
253
+ row = @builder.finish(number)
254
+ yield_row(row) if row && number >= @start_row
256
255
  end
257
256
 
257
+ # Hands the cell to the builder as the kind of its type, after the checks that are the parser's: the UTF-8 of
258
+ # the text and of an unknown type, the style index of a number or a date, the index of a shared string. A
259
+ # style decides the +[kind, pad]+ of a number and the kind of a date; the builder reads only the kind of the
260
+ # latter, so the date takes the style's own Array, without allocating one per cell.
258
261
  def finish_cell
259
262
  @in_cell = false
260
263
  column = @column || @next_column
@@ -263,43 +266,20 @@ module Calcsheet
263
266
  @next_column = column + 1
264
267
  return if @text.nil?
265
268
 
266
- value = cell_value(column)
267
- @values[column] = value unless value.nil?
268
- rescue Cast::Failure => failure
269
- (@errors ||= []) << [ column, error_text(failure), failure.reason ]
270
- end
271
-
272
- # The text of a failed cast: the cell's text, or for a number cell declared :integer that holds a fraction the
273
- # number as read (Cast#integer_number), not the full print of a double that Excel writes.
274
- def error_text(failure)
275
- failure.reason == :not_integral && (@cell_type.nil? || @cell_type == "n") ? failure.value : @text
276
- end
277
-
278
- # The cell's value, cast; before the cast, moves @last to the cell when its raw content is not blank. A
279
- # string cell leaves its text in @text, for the error of a failed cast.
280
- def cell_value(column)
281
- type = @types && @types[column]
282
269
  case @cell_type
283
270
  when nil, "n"
284
- @last = column if column > @last
285
- kind, pad = @style ? @style_kinds[@style] || style_kind(@style, column) : @styles.style_kind(0)
286
- @cast.number(utf8(@text, column), kind, type, pad: pad)
287
- when "s" then string_value(@text = shared_string(column), column, type)
288
- when "inlineStr", "str" then string_value(Escapes.decode!(utf8(@text, column)), column, type)
289
- when "b"
290
- @last = column if column > @last
291
- utf8(@text, column)
292
- @cast.boolean(boolean(@text), type)
293
- when "e"
294
- utf8(@text, column)
295
- error_value(column)
271
+ format = @style ? @style_kinds[@style] || style_kind(@style, column) : @styles.style_kind(0)
272
+ @builder.add(column, :number, utf8(@text, column), format)
273
+ when "s" then @builder.add(column, :text, shared_string(column))
274
+ when "inlineStr", "str" then @builder.add(column, :text, Escapes.decode!(utf8(@text, column)))
275
+ when "b" then @builder.add(column, :boolean, utf8(@text, column))
276
+ when "e" then @builder.add(column, :error, utf8(@text, column))
296
277
  when "d"
297
- @last = column if column > @last
298
- kind, = @style ? @style_kinds[@style] || style_kind(@style, column) : @styles.style_kind(0)
299
- @cast.iso_date(utf8(@text, column), type, kind)
278
+ format = @style ? @style_kinds[@style] || style_kind(@style, column) : @styles.style_kind(0)
279
+ @builder.add(column, :iso_date, utf8(@text, column), format)
300
280
  else
301
281
  attribute_utf8(@cell_type)
302
- string_value(utf8(@text, column), column, type)
282
+ @builder.add(column, :text, utf8(@text, column))
303
283
  end
304
284
  end
305
285
 
@@ -330,19 +310,6 @@ module Calcsheet
330
310
  raise malformed("an attribute value is not valid UTF-8", row: row)
331
311
  end
332
312
 
333
- def string_value(text, column, type)
334
- return if text.empty?
335
-
336
- @last = column if column > @last && !blank?(text)
337
- @cast.text(text, type)
338
- end
339
-
340
- def error_value(column)
341
- @last = column if column > @last
342
- (@errors ||= []) << [ column, @text, :cell_error ]
343
- nil
344
- end
345
-
346
313
  def shared_string(column)
347
314
  @table ||= @strings.call
348
315
  index = Integer(@text, 10, exception: false)
@@ -7,6 +7,7 @@ require_relative "merge_check"
7
7
  require_relative "raw_row"
8
8
  require_relative "raw_types"
9
9
  require_relative "row"
10
+ require_relative "row_builder"
10
11
  require_relative "schema"
11
12
  require_relative "sheet_selection"
12
13
 
@@ -29,14 +30,30 @@ module Calcsheet
29
30
  # breaking out of the block stops reading.
30
31
  # - +#close+: releases what the backend opened (never the source, which the book closes).
31
32
  # - optionally +#date1904?+: the workbook counts date serials from 1904.
33
+ # - optionally +#each_sheet { |SheetInfo| }+ (an Enumerator without a block): the sheets of +#sheets+, in order,
34
+ # found only as far as the caller reads them, for a format that must scan to list its sheets (ODS). Without
35
+ # +precheck_sheets+, a read selects its sheets through it, so that the default sheet does not cost a scan of
36
+ # the whole file.
37
+ # - optionally +#end_row(sheet_index)+ -> Array(Integer, Integer | nil), nil: after an +each_raw_batch+ of the
38
+ # sheet that read to its end, the number and line of the row that would follow its last one, blank or not, when
39
+ # the blank rows at the end are rows of the sheet (CSV records). Without it, or nil, they are not: a sheet ends
40
+ # with its last non-blank row.
32
41
  #
33
- # Rows are numbered as the user sees them; +values+ are in file order with trailing blank cells trimmed.
42
+ # Rows are numbered as the user sees them; +values+ are in file order, trimmed after the last non-blank cell or,
43
+ # after it, the last whitespace text that the declared type keeps as text (:string, or the native value).
34
44
  # Each cell is cast with +cast+ (a Cast) and the declared type +types[column_index]+, where +types+ is nil or
35
45
  # an object answering +[](column_index)+ with a type of TYPES or nil (an Array, or a RawTypes, which also gives
36
46
  # a type to the columns beyond its entries); nil means the native value. A backend rescues Cast::Failure per
37
47
  # cell, puts nil in +values+ and appends +[column_index, raw_value, reason]+ to +errors+. The Options of the read
38
48
  # (which may override those of the book, but not OPEN_KEYS) are +cast.options+.
39
49
  #
50
+ # calcsheet-native depends on this contract: change it only in a minor release and together with the protocol
51
+ # version (see NativeBackends).
52
+ #
53
+ # The Ruby backends hand their raw cells to a RowBuilder, which applies these rules in one place: the casts,
54
+ # the failures (+errors+ nil when none, else sorted by column), the trimming (a :text of whitespace only is blank,
55
+ # every other cell is not) and the failures of the whitespace after the row.
56
+ #
40
57
  # Reading itself is the facade's: Book builds one per read.
41
58
  class Reading
42
59
  # The read options; every other keyword is an Options override.
@@ -44,7 +61,7 @@ module Calcsheet
44
61
  # The Options the book applies when the file is opened (the source, the zip archive, the backend): a read cannot
45
62
  # override them.
46
63
  OPEN_KEYS = %i[max_file_size max_uncompressed_size max_compression_ratio compression_ratio_floor max_zip_entries
47
- max_sheets max_cell_bytes].freeze
64
+ max_sheets max_cell_bytes backend precheck_sheets].freeze
48
65
  # The errors of a row without any, shared.
49
66
  NO_ERRORS = [].freeze
50
67
  # A character that is not whitespace: a text is blank unless it has one. A search, not an anchored
@@ -66,7 +83,8 @@ module Calcsheet
66
83
  attr_writer :open_check
67
84
 
68
85
  # @api private
69
- # @param sheets [Array<SheetInfo>] every sheet of the book
86
+ # @param sheets [Enumerable<SheetInfo>] every sheet of the book, in order: an Array, or a SheetList that finds
87
+ # them as far as the selection reads
70
88
  # @param options [Options] the options of the book
71
89
  # @param overrides [Hash] Options overrides of this read
72
90
  # @raise [ArgumentError] on an invalid read option or an override of an OPEN_KEYS option
@@ -221,7 +239,7 @@ module Calcsheet
221
239
  @backend.each_raw_batch(sheet.index, size: size, cast: @cast, types: @types, start_row: first) do |batch|
222
240
  batch.each do |raw|
223
241
  unless @skip_blank
224
- (expected...raw.number).each { |number| yield blank_row(raw, number) }
242
+ (expected...raw.number).each { |number| yield blank_row(number, raw.number, raw.line) }
225
243
  expected = raw.number + 1
226
244
  end
227
245
  yield raw
@@ -229,11 +247,15 @@ module Calcsheet
229
247
  # A book closed while its rows were yielded stops the read before the backend reads on.
230
248
  @open_check&.call
231
249
  end
250
+ return if @skip_blank || !@backend.respond_to?(:end_row)
251
+
252
+ number, line = @backend.end_row(sheet.index)
253
+ (expected...number).each { |blank| yield blank_row(blank, number, line) } if number
232
254
  end
233
255
 
234
- # A blank row before +following+: a blank record or row is one physical line.
235
- def blank_row(following, number)
236
- RawRow.new(number: number, line: following.line && following.line - (following.number - number), values: [])
256
+ # A blank row before the row +following+ that starts at +line+: a blank record or row is one physical line.
257
+ def blank_row(number, following, line)
258
+ RawRow.new(number: number, line: line && line - (following - number), values: [])
237
259
  end
238
260
 
239
261
  def convert(sheet, raw)