calcsheet 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +124 -9
- data/lib/calcsheet/book.rb +25 -9
- data/lib/calcsheet/cell_range.rb +54 -0
- data/lib/calcsheet/configuration.rb +10 -0
- data/lib/calcsheet/display_format.rb +75 -0
- data/lib/calcsheet/errors.rb +5 -0
- data/lib/calcsheet/formula.rb +40 -0
- data/lib/calcsheet/limited_inflater.rb +16 -0
- data/lib/calcsheet/native_backends.rb +139 -0
- data/lib/calcsheet/option_validation.rb +4 -1
- data/lib/calcsheet/raw_row.rb +1 -1
- data/lib/calcsheet/readers/csv_reader.rb +43 -20
- data/lib/calcsheet/readers/csv_records.rb +193 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
- data/lib/calcsheet/readers/ods/scanner.rb +33 -6
- data/lib/calcsheet/readers/ods_reader.rb +16 -1
- data/lib/calcsheet/readers/sax_sheet.rb +6 -48
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
- data/lib/calcsheet/reading.rb +29 -7
- data/lib/calcsheet/row.rb +2 -2
- data/lib/calcsheet/row_builder.rb +235 -0
- data/lib/calcsheet/sheet_list.rb +25 -0
- data/lib/calcsheet/sheet_selection.rb +3 -2
- data/lib/calcsheet/style.rb +114 -0
- data/lib/calcsheet/version.rb +1 -1
- data/lib/calcsheet/write_book.rb +49 -17
- data/lib/calcsheet/write_sheet.rb +220 -18
- data/lib/calcsheet/writers/csv_writer.rb +18 -7
- data/lib/calcsheet/writers/ods_writer.rb +249 -16
- data/lib/calcsheet/writers/open_formula.rb +67 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
- data/lib/calcsheet/writers/zip_buffered.rb +20 -1
- data/lib/calcsheet.rb +31 -8
- metadata +11 -2
|
@@ -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,
|
|
19
|
-
# it (see Scanner#io_at_sheet); parsing stops at the end of
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
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 = {
|
|
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,
|
|
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+, +@
|
|
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
|
|
142
|
-
#
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
#
|
|
366
|
-
def
|
|
367
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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, :
|
|
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
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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 &&
|
|
132
|
-
raise TooManySheetsError.new(limit: :max_sheets, max: @max_sheets, actual:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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)
|
|
18
|
-
#
|
|
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
|
-
#
|
|
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(
|
|
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,
|
|
17
|
-
#
|
|
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+, +@
|
|
84
|
-
# shared strings) and +@table+ (the SharedStrings, loaded at the first +t="s"+ cell),
|
|
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)
|
|
90
|
-
#
|
|
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
|
-
|
|
105
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
when "
|
|
288
|
-
when "
|
|
289
|
-
when "
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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)
|
data/lib/calcsheet/reading.rb
CHANGED
|
@@ -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
|
|
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 [
|
|
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,
|
|
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,
|
|
236
|
-
RawRow.new(number: number, line:
|
|
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)
|