calcsheet 0.1.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE.txt +21 -0
- data/README.md +921 -0
- data/lib/calcsheet/book.rb +164 -0
- data/lib/calcsheet/cast.rb +578 -0
- data/lib/calcsheet/column.rb +117 -0
- data/lib/calcsheet/column_letter.rb +34 -0
- data/lib/calcsheet/configuration.rb +186 -0
- data/lib/calcsheet/decimal_text.rb +19 -0
- data/lib/calcsheet/entry_name.rb +28 -0
- data/lib/calcsheet/errors.rb +186 -0
- data/lib/calcsheet/excel_serial.rb +65 -0
- data/lib/calcsheet/format_detector.rb +109 -0
- data/lib/calcsheet/formula_protection.rb +31 -0
- data/lib/calcsheet/grid.rb +20 -0
- data/lib/calcsheet/header_keys.rb +66 -0
- data/lib/calcsheet/limited_inflater.rb +326 -0
- data/lib/calcsheet/merge_check.rb +41 -0
- data/lib/calcsheet/odf.rb +45 -0
- data/lib/calcsheet/option_validation.rb +160 -0
- data/lib/calcsheet/options.rb +10 -0
- data/lib/calcsheet/raw_row.rb +18 -0
- data/lib/calcsheet/raw_types.rb +15 -0
- data/lib/calcsheet/readers/csv_dialect.rb +264 -0
- data/lib/calcsheet/readers/csv_input.rb +128 -0
- data/lib/calcsheet/readers/csv_reader.rb +239 -0
- data/lib/calcsheet/readers/ods/cell_formats.rb +115 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +594 -0
- data/lib/calcsheet/readers/ods/count.rb +30 -0
- data/lib/calcsheet/readers/ods/cursor.rb +121 -0
- data/lib/calcsheet/readers/ods/patterns.rb +70 -0
- data/lib/calcsheet/readers/ods/row_seek.rb +157 -0
- data/lib/calcsheet/readers/ods/scanner.rb +302 -0
- data/lib/calcsheet/readers/ods/style_scan.rb +108 -0
- data/lib/calcsheet/readers/ods/tag_text.rb +76 -0
- data/lib/calcsheet/readers/ods_reader.rb +71 -0
- data/lib/calcsheet/readers/ox_errors.rb +40 -0
- data/lib/calcsheet/readers/references.rb +62 -0
- data/lib/calcsheet/readers/row_batches.rb +45 -0
- data/lib/calcsheet/readers/sax_sheet.rb +108 -0
- data/lib/calcsheet/readers/stream_remainder.rb +66 -0
- data/lib/calcsheet/readers/tag_scan.rb +119 -0
- data/lib/calcsheet/readers/xlsx/escapes.rb +36 -0
- data/lib/calcsheet/readers/xlsx/local_name.rb +54 -0
- data/lib/calcsheet/readers/xlsx/number_format.rb +89 -0
- data/lib/calcsheet/readers/xlsx/row_skipper.rb +162 -0
- data/lib/calcsheet/readers/xlsx/shared_strings.rb +209 -0
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +435 -0
- data/lib/calcsheet/readers/xlsx/styles.rb +156 -0
- data/lib/calcsheet/readers/xlsx/workbook_meta.rb +228 -0
- data/lib/calcsheet/readers/xlsx_reader.rb +122 -0
- data/lib/calcsheet/reading.rb +281 -0
- data/lib/calcsheet/row.rb +110 -0
- data/lib/calcsheet/sample.rb +10 -0
- data/lib/calcsheet/schema.rb +140 -0
- data/lib/calcsheet/sheet_info.rb +21 -0
- data/lib/calcsheet/sheet_names.rb +114 -0
- data/lib/calcsheet/sheet_selection.rb +58 -0
- data/lib/calcsheet/source.rb +263 -0
- data/lib/calcsheet/stream_body.rb +81 -0
- data/lib/calcsheet/time_zone.rb +96 -0
- data/lib/calcsheet/untrusted.rb +32 -0
- data/lib/calcsheet/version.rb +6 -0
- data/lib/calcsheet/write_book.rb +202 -0
- data/lib/calcsheet/write_sheet.rb +282 -0
- data/lib/calcsheet/writers/csv_writer.rb +138 -0
- data/lib/calcsheet/writers/ods_writer.rb +437 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +350 -0
- data/lib/calcsheet/writers/xml_text.rb +35 -0
- data/lib/calcsheet/writers/zip_buffered.rb +55 -0
- data/lib/calcsheet/zip_container.rb +190 -0
- data/lib/calcsheet/zip_directory.rb +202 -0
- data/lib/calcsheet/zip_end_record.rb +117 -0
- data/lib/calcsheet.rb +295 -0
- metadata +200 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
require_relative "../tag_scan"
|
|
5
|
+
|
|
6
|
+
module Calcsheet
|
|
7
|
+
module Readers
|
|
8
|
+
module Ods
|
|
9
|
+
# A forward-only view of the entry that finds the next tag a regexp matches, carrying its position across
|
|
10
|
+
# chunks, so that every byte is scanned once. +sheet+ and +row+ locate its errors.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class Cursor
|
|
14
|
+
include TagScan
|
|
15
|
+
|
|
16
|
+
# "<" and what may be the beginning of a start or end tag's name, up to the end of the buffer.
|
|
17
|
+
PARTIAL_NAME = %r{\G</?[^\s>/<"'=]*\z}n
|
|
18
|
+
|
|
19
|
+
# @return [String, nil] the sheet name its errors name, once known
|
|
20
|
+
attr_accessor :sheet
|
|
21
|
+
# @return [Integer, nil] the last row passed over, for its errors
|
|
22
|
+
attr_accessor :row
|
|
23
|
+
|
|
24
|
+
# @param io [#readpartial] read forward only, never closed here
|
|
25
|
+
# @param entry [String] the entry name, for errors
|
|
26
|
+
def initialize(io, entry: "content.xml")
|
|
27
|
+
@io = io
|
|
28
|
+
@entry = entry
|
|
29
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
30
|
+
@from = 0
|
|
31
|
+
@discarded = 0
|
|
32
|
+
@sheet = nil
|
|
33
|
+
@row = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Integer] the bytes of the entry before the cursor
|
|
37
|
+
def position
|
|
38
|
+
@discarded + @from
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String] the bytes read after the last tag found
|
|
42
|
+
def rest
|
|
43
|
+
@buffer.byteslice(@from, @buffer.bytesize - @from)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param pattern [Regexp] matches the opening of the wanted tags, and "<!" and "<?"
|
|
47
|
+
# @return [String, nil] the next wanted tag; nil at the end of the stream
|
|
48
|
+
# @raise [MalformedFileError] for a tag longer than MAX_TAG_BYTES, or a "<" inside an attribute value
|
|
49
|
+
def next_tag(pattern)
|
|
50
|
+
loop do
|
|
51
|
+
start = @buffer.byteindex(pattern, @from)
|
|
52
|
+
if start.nil?
|
|
53
|
+
discard(partial_name_start)
|
|
54
|
+
return unless read_more
|
|
55
|
+
elsif enclosing_tag?(@from, start)
|
|
56
|
+
raise inside_attribute
|
|
57
|
+
elsif (markup = markup_at(start))
|
|
58
|
+
case markup
|
|
59
|
+
when :other then @from = start + 1
|
|
60
|
+
when :incomplete
|
|
61
|
+
discard(start)
|
|
62
|
+
return unless read_more
|
|
63
|
+
else
|
|
64
|
+
position = skip_markup(start, *markup) or return
|
|
65
|
+
@from = position
|
|
66
|
+
end
|
|
67
|
+
else
|
|
68
|
+
return tag_at(start)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Reads on until the tag that starts at +start+ is complete. Rescans it at most once per chunk, and it is
|
|
76
|
+
# at most MAX_TAG_BYTES long.
|
|
77
|
+
def tag_at(start)
|
|
78
|
+
loop do
|
|
79
|
+
match = TAG.match(@buffer, start)
|
|
80
|
+
if match
|
|
81
|
+
raise too_long if match.end(0) - start > MAX_TAG_BYTES
|
|
82
|
+
|
|
83
|
+
@from = match.end(0)
|
|
84
|
+
return match[0]
|
|
85
|
+
end
|
|
86
|
+
raise too_long if @buffer.bytesize - start > MAX_TAG_BYTES
|
|
87
|
+
|
|
88
|
+
discard(start)
|
|
89
|
+
start = 0
|
|
90
|
+
return unless read_more
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def too_long
|
|
95
|
+
located("a start tag is longer than #{MAX_TAG_BYTES} bytes")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Where a wanted tag cut by the end of the buffer may begin (see TagScan#partial_tag).
|
|
99
|
+
# @raise [MalformedFileError] for a name longer than MAX_TAG_BYTES, or a last "<" inside the tag before it
|
|
100
|
+
def partial_name_start
|
|
101
|
+
last = partial_tag(@from, PARTIAL_NAME)
|
|
102
|
+
raise long_name if @buffer.bytesize - last > MAX_TAG_BYTES
|
|
103
|
+
|
|
104
|
+
last
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def located(message)
|
|
108
|
+
return MalformedFileError.at(message, sheet: @sheet, row: @row) if @sheet
|
|
109
|
+
|
|
110
|
+
MalformedFileError.new("#{@entry}: #{message}", row: @row)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def discard(position)
|
|
114
|
+
@discarded += position
|
|
115
|
+
@from = 0
|
|
116
|
+
super
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../odf"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
module Readers
|
|
7
|
+
module Ods
|
|
8
|
+
# The regexps of the tags the byte scanners look for (Scanner, StyleScan, RowSeek), and the names they compare,
|
|
9
|
+
# built for the prefixes of a document. Each tag regexp also matches "<!" and "<?", which may open a comment, a
|
|
10
|
+
# CDATA section or a processing instruction.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class Patterns
|
|
14
|
+
# The elements of the data styles followed by StyleScan#data_style.
|
|
15
|
+
NUMBER_ELEMENTS = "(?:date-style|number-style|number|text|embedded-text|fraction|scientific-number|" \
|
|
16
|
+
"fill-character|hours|minutes|seconds|am-pm)"
|
|
17
|
+
|
|
18
|
+
# The tag regexps (+head+: the styles before the first table, +tables+, +rows+: the row, column, row group and
|
|
19
|
+
# table tags of a table), the qualified names compared with TagText#element_name (+table+, +row+, +column+,
|
|
20
|
+
# +style+, +table_properties+, +number_qualifier+, +qualified_rows_repeated+, +qualified_columns_repeated+),
|
|
21
|
+
# and the attribute regexps that TagText#attribute reads (the others).
|
|
22
|
+
attr_reader :head, :tables, :rows, :table, :row, :column, :style, :table_properties, :display, :family,
|
|
23
|
+
:style_name, :data_style_name, :parent_style_name, :table_name, :table_style, :rows_repeated, :qualified_rows_repeated,
|
|
24
|
+
:columns_repeated, :qualified_columns_repeated, :number_qualifier, :decimal_places, :grouping, :min_integer_digits
|
|
25
|
+
|
|
26
|
+
# @param prefixes [Hash{Symbol => String}] the prefixes of the table, style and number namespaces
|
|
27
|
+
def initialize(prefixes)
|
|
28
|
+
table = Odf.qualifier(prefixes[:table])
|
|
29
|
+
style = Odf.qualifier(prefixes[:style])
|
|
30
|
+
number = Odf.qualifier(prefixes[:number])
|
|
31
|
+
escaped_table = Regexp.escape(table)
|
|
32
|
+
escaped_style = Regexp.escape(style)
|
|
33
|
+
escaped_number = Regexp.escape(number)
|
|
34
|
+
groups = "(?:-row-group|-header-rows|-rows)"
|
|
35
|
+
@table = "#{table}table".b
|
|
36
|
+
@row = "#{table}table-row".b
|
|
37
|
+
@column = "#{table}table-column".b
|
|
38
|
+
@style = "#{style}style".b
|
|
39
|
+
@table_properties = "#{style}table-properties".b
|
|
40
|
+
@number_qualifier = number.b
|
|
41
|
+
@tables = Regexp.new("<(?:[!?]|/?#{escaped_table}table[\\s>/])".b, Regexp::NOENCODING)
|
|
42
|
+
@rows = Regexp.new("<(?:[!?]|#{escaped_table}table(?:-row|-column|#{groups})?[\\s>/]|" \
|
|
43
|
+
"/#{escaped_table}table#{groups}?[\\s>])".b, Regexp::NOENCODING)
|
|
44
|
+
@head = Regexp.new("<(?:[!?]|/?#{escaped_style}style[\\s>/]|#{escaped_style}(?:table-properties|map)[\\s>/]|" \
|
|
45
|
+
"/?#{escaped_number}#{NUMBER_ELEMENTS}[\\s>/]|#{escaped_table}table[\\s>/])".b, Regexp::NOENCODING)
|
|
46
|
+
@display = attribute("#{table}display")
|
|
47
|
+
@table_name = attribute("#{table}name")
|
|
48
|
+
@table_style = attribute("#{table}style-name")
|
|
49
|
+
@rows_repeated = attribute("#{table}number-rows-repeated")
|
|
50
|
+
@columns_repeated = attribute("#{table}number-columns-repeated")
|
|
51
|
+
@qualified_rows_repeated = Odf.qualified(prefixes[:table], "number-rows-repeated")
|
|
52
|
+
@qualified_columns_repeated = Odf.qualified(prefixes[:table], "number-columns-repeated")
|
|
53
|
+
@family = attribute("#{style}family")
|
|
54
|
+
@style_name = attribute("#{style}name")
|
|
55
|
+
@data_style_name = attribute("#{style}data-style-name")
|
|
56
|
+
@parent_style_name = attribute("#{style}parent-style-name")
|
|
57
|
+
@decimal_places = attribute("#{number}decimal-places")
|
|
58
|
+
@grouping = attribute("#{number}grouping")
|
|
59
|
+
@min_integer_digits = attribute("#{number}min-integer-digits")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def attribute(name)
|
|
65
|
+
Regexp.new("\\s#{Regexp.escape(name)}\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')".b, Regexp::NOENCODING)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
require_relative "../../grid"
|
|
5
|
+
require_relative "../../untrusted"
|
|
6
|
+
require_relative "count"
|
|
7
|
+
require_relative "tag_text"
|
|
8
|
+
|
|
9
|
+
module Calcsheet
|
|
10
|
+
module Readers
|
|
11
|
+
module Ods
|
|
12
|
+
# Passes over the rows of a table before a wanted one with a Cursor, without parsing them: it counts the row
|
|
13
|
+
# start tags with their +number-rows-repeated+, keeps the start tags of the row groups that are open (at most
|
|
14
|
+
# MAX_GROUPS, of at most +max_column_bytes+ together) and, when a cell format may come from a column's default
|
|
15
|
+
# cell style, the column elements. Tables nested in the table are passed over whole.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class RowSeek
|
|
19
|
+
include TagText
|
|
20
|
+
|
|
21
|
+
# The most row groups open around a row: the parser (Ox) refuses a deeper nesting anyway.
|
|
22
|
+
MAX_GROUPS = 1_000
|
|
23
|
+
|
|
24
|
+
# The column elements of a table, kept for the parser: each becomes an empty element. Only the first
|
|
25
|
+
# Grid::MAX_COLUMNS columns can hold cells, and the kept bytes are bounded.
|
|
26
|
+
class Columns
|
|
27
|
+
# @return [String] the kept column elements
|
|
28
|
+
attr_reader :xml
|
|
29
|
+
|
|
30
|
+
# @param sheet [String] the sheet name, for errors
|
|
31
|
+
# @param max_bytes [Integer] the most bytes the kept elements may take
|
|
32
|
+
def initialize(sheet, max_bytes)
|
|
33
|
+
@sheet = sheet
|
|
34
|
+
@max_bytes = max_bytes
|
|
35
|
+
@xml = String.new(encoding: Encoding::BINARY)
|
|
36
|
+
@count = 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param tag [String] a table-column start tag
|
|
40
|
+
# @param repeated [Integer] its number-columns-repeated
|
|
41
|
+
# @raise [MalformedFileError] when the kept elements are longer than +max_bytes+
|
|
42
|
+
def add(tag, repeated)
|
|
43
|
+
return if @count >= Grid::MAX_COLUMNS
|
|
44
|
+
|
|
45
|
+
@count += repeated
|
|
46
|
+
@xml << (TagText.self_closing?(tag) ? tag : "#{tag.byteslice(0, tag.bytesize - 1)}/>")
|
|
47
|
+
return if @xml.bytesize <= @max_bytes
|
|
48
|
+
|
|
49
|
+
raise MalformedFileError.at("the columns of the sheet are longer than #{@max_bytes} bytes", sheet: @sheet)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param cursor [Cursor] after the start tag of the table, with the sheet name for errors
|
|
54
|
+
# @param patterns [Patterns]
|
|
55
|
+
# @param columns [Boolean] whether the column elements are kept
|
|
56
|
+
# @param max_column_bytes [Integer] the most bytes the kept column elements may take, and the kept row group
|
|
57
|
+
# start tags
|
|
58
|
+
def initialize(cursor, patterns, columns:, max_column_bytes:)
|
|
59
|
+
@cursor = cursor
|
|
60
|
+
@patterns = patterns
|
|
61
|
+
@max_bytes = max_column_bytes
|
|
62
|
+
@columns = Columns.new(cursor.sheet, max_column_bytes) if columns
|
|
63
|
+
@groups = []
|
|
64
|
+
@group_bytes = 0
|
|
65
|
+
@nested = 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param row [Integer] the first row wanted
|
|
69
|
+
# @param head [String] the binary bytes the parser reads first: a declaration and the table start tag
|
|
70
|
+
# @return [Array(String, Integer), nil] what the parser reads up to the cursor (+head+, the kept columns, the
|
|
71
|
+
# open row groups, the row start tag holding +row+ and the bytes read after it), and the number of that row;
|
|
72
|
+
# nil when the table ends first
|
|
73
|
+
# @raise [MalformedFileError] for an invalid repeat count, more than MAX_GROUPS open row groups or their start
|
|
74
|
+
# tags longer than +max_column_bytes+, or an error of the cursor
|
|
75
|
+
def seek(row, head)
|
|
76
|
+
# Locals for what each row tag reads. The invariants of the loop: +number+ is the number of the next row
|
|
77
|
+
# start tag (the first row of its run), the cursor is right after the last tag found (so +cursor.rest+ starts
|
|
78
|
+
# after +tag+), +@groups+ holds the start tags of the row groups open there, in nesting order, and
|
|
79
|
+
# +@nested+ the depth inside tables nested in the table, whose rows do not count.
|
|
80
|
+
cursor = @cursor
|
|
81
|
+
patterns = @patterns
|
|
82
|
+
number = 1
|
|
83
|
+
while (tag = cursor.next_tag(patterns.rows))
|
|
84
|
+
name = element_name(tag)
|
|
85
|
+
if name == patterns.table
|
|
86
|
+
return if closing?(tag) && @nested.zero?
|
|
87
|
+
|
|
88
|
+
nest(tag)
|
|
89
|
+
elsif @nested.positive?
|
|
90
|
+
next
|
|
91
|
+
elsif name == patterns.column
|
|
92
|
+
# Checked also when the columns are not kept, as ContentParser checks them.
|
|
93
|
+
count = repeat(tag, patterns.columns_repeated, patterns.qualified_columns_repeated, nil)
|
|
94
|
+
@columns&.add(tag, count)
|
|
95
|
+
elsif name == patterns.row
|
|
96
|
+
count = repeat(tag, patterns.rows_repeated, patterns.qualified_rows_repeated, number)
|
|
97
|
+
return [ head + @columns&.xml.to_s + @groups.join + tag + cursor.rest, number ] if number + count > row
|
|
98
|
+
|
|
99
|
+
number += count
|
|
100
|
+
cursor.row = number - 1
|
|
101
|
+
else
|
|
102
|
+
group(tag)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# A table inside the table: its rows are not the sheet's.
|
|
111
|
+
def nest(tag)
|
|
112
|
+
if closing?(tag) then @nested -= 1
|
|
113
|
+
elsif !self_closing?(tag) then @nested += 1
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A row group (table:table-row-group, -header-rows, -rows) opens or closes.
|
|
118
|
+
def group(tag)
|
|
119
|
+
if closing?(tag)
|
|
120
|
+
@group_bytes -= @groups.pop.bytesize unless @groups.empty?
|
|
121
|
+
elsif !self_closing?(tag)
|
|
122
|
+
open_group(tag)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def open_group(tag)
|
|
127
|
+
if @groups.size == MAX_GROUPS
|
|
128
|
+
raise MalformedFileError.at("the sheet has more than #{MAX_GROUPS} nested row groups", sheet: @cursor.sheet)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@group_bytes += tag.bytesize
|
|
132
|
+
if @group_bytes > @max_bytes
|
|
133
|
+
raise MalformedFileError.at("the row groups of the sheet are longer than #{@max_bytes} bytes", sheet: @cursor.sheet)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
@groups << tag
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The repeat count of a row or column start tag, 1 without the attribute, refused like ContentParser does.
|
|
140
|
+
# @param pattern [Regexp] the attribute (Patterns#rows_repeated or #columns_repeated)
|
|
141
|
+
# @param qualified [String] its name, for the error
|
|
142
|
+
# @param row [Integer, nil] the number of the row, for the error; nil for a column
|
|
143
|
+
# @raise [MalformedFileError] unless the count is a positive number (Count.parse)
|
|
144
|
+
def repeat(tag, pattern, qualified, row)
|
|
145
|
+
value = attribute(tag, pattern)
|
|
146
|
+
return 1 if value.nil?
|
|
147
|
+
|
|
148
|
+
count = Count.parse(value)
|
|
149
|
+
return count if count&.positive?
|
|
150
|
+
|
|
151
|
+
raise MalformedFileError.at("#{qualified} must be a positive number, got #{Untrusted.show(value)}",
|
|
152
|
+
sheet: @cursor.sheet, row: row)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
require_relative "../../odf"
|
|
5
|
+
require_relative "../../sheet_info"
|
|
6
|
+
require_relative "../../untrusted"
|
|
7
|
+
require_relative "../stream_remainder"
|
|
8
|
+
require_relative "cell_formats"
|
|
9
|
+
require_relative "cursor"
|
|
10
|
+
require_relative "patterns"
|
|
11
|
+
require_relative "row_seek"
|
|
12
|
+
require_relative "style_scan"
|
|
13
|
+
require_relative "tag_text"
|
|
14
|
+
|
|
15
|
+
module Calcsheet
|
|
16
|
+
module Readers
|
|
17
|
+
# The parts of an ODS spreadsheet (OpenDocument) that OdsReader reads: the byte scanners that find its sheets,
|
|
18
|
+
# rows and styles (Scanner, Cursor, StyleScan, RowSeek, with Patterns, TagText, CellFormats and Count), and the
|
|
19
|
+
# ContentParser of a table.
|
|
20
|
+
module Ods
|
|
21
|
+
# Finds the sheets of an ODS +content.xml+ without parsing it: every sheet is a table element of that one
|
|
22
|
+
# entry, so its start is found by inflating the entry from the beginning and scanning the bytes for the table
|
|
23
|
+
# start tag. Rows before a wanted one are passed over the same way, counting the row start tags with their
|
|
24
|
+
# +number-rows-repeated+. A row costs a regexp scan, not a SAX callback per element.
|
|
25
|
+
#
|
|
26
|
+
# The namespace prefixes are read from the declarations of the root element (+t:table+ is as valid as
|
|
27
|
+
# +table:table+), and a sheet is hidden when its automatic table style says +table:display="false"+; the
|
|
28
|
+
# automatic styles come before the first table and are scanned on the way. So are the data styles that change
|
|
29
|
+
# how a value reads: a date style that shows a time or none, and a plain zero-padding number style
|
|
30
|
+
# (+00000+), with the common styles of styles.xml; see #cell_formats and StyleScan. RowSeek passes over the rows.
|
|
31
|
+
#
|
|
32
|
+
# Each call inflates the entry from its start: deflate cannot seek. Comments, CDATA sections and processing
|
|
33
|
+
# instructions are passed over; a "<" inside the tag before a wanted tag or a markup opening usually raises
|
|
34
|
+
# MalformedFileError (see Cursor#enclosing_tag?; a tag cut by a chunk boundary may be passed over unchecked, ADR
|
|
35
|
+
# 008). Every byte is scanned once (see Cursor) and the buffer stays bounded: a wanted tag (table, row, column,
|
|
36
|
+
# row group, style) may be at most Cursor::MAX_TAG_BYTES long, other tags are passed over unbuffered, the part
|
|
37
|
+
# before the first table is at most MAX_HEAD_BYTES, a namespace prefix at most MAX_PREFIX_BYTES, and the document
|
|
38
|
+
# may have at most +max_sheets+ tables.
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
class Scanner
|
|
42
|
+
include TagText
|
|
43
|
+
|
|
44
|
+
# The bytes before the first table of content.xml (the automatic styles), and of styles.xml, the scanner
|
|
45
|
+
# passes over; and the column elements and the row group start tags RowSeek keeps (each).
|
|
46
|
+
MAX_HEAD_BYTES = 16_777_216
|
|
47
|
+
# Longer prefixes would not fit in the piece of a tag the cursor keeps between chunks.
|
|
48
|
+
MAX_PREFIX_BYTES = 256
|
|
49
|
+
# An XML name without a colon (NCName), approximated with Unicode letters and digits.
|
|
50
|
+
PREFIX = /\A[[:alpha:]_][[:alnum:]_.\-\u00B7]*\z/
|
|
51
|
+
# The XML declaration a Cut starts with: content.xml is UTF-8.
|
|
52
|
+
DECLARATION = '<?xml version="1.0" encoding="UTF-8"?>'.b.freeze
|
|
53
|
+
# The namespaces whose prefixes the scanner and ContentParser use.
|
|
54
|
+
NAMESPACES = Odf::NAMESPACES.slice(:office, :table, :text, :style, :number, :calcext).freeze
|
|
55
|
+
# The first element (or a "<!", "<?" markup to pass over): the root, which declares the namespaces.
|
|
56
|
+
ROOT = /<(?:[!?]|[A-Za-z_])/n
|
|
57
|
+
# A namespace declaration of the root start tag: its prefix (none for the default namespace) and its URI.
|
|
58
|
+
DECLARATIONS = /\sxmlns(?::([^\s=]+))?\s*=\s*(?:"([^"]*)"|'([^']*)')/n
|
|
59
|
+
|
|
60
|
+
# The stream of one sheet from its table start tag, or from a row start tag inside it, after an XML
|
|
61
|
+
# declaration and the start tags of the table and the row groups the row is in: a document the parser can
|
|
62
|
+
# read until the end of the table. +first_row+ is the number of the first row it holds.
|
|
63
|
+
#
|
|
64
|
+
# A synthetic document: its root is the table, without the namespace declarations of the real root, which Ox
|
|
65
|
+
# does not check (ContentParser matches the qualified names with the prefixes Scanner#prefixes read), and the
|
|
66
|
+
# parser stops at the end tag of the table, before it would miss the end tags of the real ancestors.
|
|
67
|
+
class Cut < StreamRemainder
|
|
68
|
+
# @return [Integer] the number of the first row of the stream
|
|
69
|
+
attr_reader :first_row
|
|
70
|
+
|
|
71
|
+
# @param first_row [Integer] the number of the first row in +head+
|
|
72
|
+
def initialize(head, io, first_row, bound: nil)
|
|
73
|
+
super(head, io, bound: bound)
|
|
74
|
+
@first_row = first_row
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def close
|
|
78
|
+
@io.close
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param zip [ZipContainer, #stream] the opened spreadsheet
|
|
83
|
+
# @param entry [String] the entry holding the tables
|
|
84
|
+
# @param styles [String, nil] the entry holding the common styles (styles.xml), when the spreadsheet has one
|
|
85
|
+
# @param max_sheets [Integer, nil] the most tables #sheets accepts; nil for no limit
|
|
86
|
+
def initialize(zip, entry: "content.xml", styles: nil, max_sheets: nil)
|
|
87
|
+
@zip = zip
|
|
88
|
+
@entry = entry
|
|
89
|
+
@styles_entry = styles
|
|
90
|
+
@max_sheets = max_sheets
|
|
91
|
+
@prefixes = nil
|
|
92
|
+
@patterns = nil
|
|
93
|
+
@hidden_styles = nil
|
|
94
|
+
@head_formats = nil
|
|
95
|
+
@cell_formats = nil
|
|
96
|
+
@sheets = nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Hash{Symbol => String}] the prefix of each namespace of NAMESPACES ("" for the default namespace);
|
|
100
|
+
# the conventional one for a namespace the root element does not declare
|
|
101
|
+
# @raise [MalformedFileError] when the document has no root element
|
|
102
|
+
def prefixes
|
|
103
|
+
walk { |cursor| read_root(cursor) } unless @prefixes
|
|
104
|
+
@prefixes
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The cell styles whose data style decides how a value reads: the automatic styles before the first table and
|
|
108
|
+
# the common styles of the styles entry, read once (see CellFormats). The automatic styles win.
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash{String => Array(Symbol, Integer | nil)}] +[kind, pad]+ by cell style name, as
|
|
111
|
+
# Xlsx::WorkbookMeta#style_kind: +[:date, nil]+ or +[:datetime, nil]+ for a date style without or with a
|
|
112
|
+
# time, +[:general, digits]+ for a plain zero-padding number style; other styles are left out
|
|
113
|
+
# @raise [MalformedFileError] for a damaged styles entry or one longer than MAX_HEAD_BYTES
|
|
114
|
+
def cell_formats
|
|
115
|
+
@cell_formats ||= begin
|
|
116
|
+
walk { |cursor| first_table(cursor) } unless @head_formats
|
|
117
|
+
common = @styles_entry ? common_formats : CellFormats.new
|
|
118
|
+
common.merge(@head_formats).to_h
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Scans the whole entry once; the result is kept.
|
|
123
|
+
#
|
|
124
|
+
# @return [Array<SheetInfo>] the tables in document order
|
|
125
|
+
# @raise [MalformedFileError] for a damaged document, a sheet name that is not UTF-8 or a bound exceeded
|
|
126
|
+
# @raise [TooManySheetsError] for more tables than +max_sheets+
|
|
127
|
+
def sheets
|
|
128
|
+
@sheets ||= walk do |cursor|
|
|
129
|
+
list = []
|
|
130
|
+
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)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
list << sheet_info(tag, list.size)
|
|
136
|
+
end
|
|
137
|
+
list.freeze
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Inflates the entry from its start and stops at the table of the sheet, or at the row start tag holding
|
|
142
|
+
# +row+ (a repeated row holds several).
|
|
143
|
+
#
|
|
144
|
+
# @param index [Integer] the 0-based position of the sheet
|
|
145
|
+
# @param row [Integer] the first row wanted
|
|
146
|
+
# @return [Cut, nil] the stream, which the caller must close; nil when the sheet has no row +row+ or later
|
|
147
|
+
# @raise [MalformedFileError] when no table is found at +index+ (#sheets listed it, or it was never listed),
|
|
148
|
+
# for a damaged document, an invalid repeat count or a bound exceeded
|
|
149
|
+
def io_at_sheet(index, row: 1)
|
|
150
|
+
# The Cursor passes over the tables and rows before the one wanted without the max_cell_bytes bound; the
|
|
151
|
+
# Cut applies it to what the parser reads.
|
|
152
|
+
stream = @zip.stream(@entry, bound_tokens: false)
|
|
153
|
+
cut = nil
|
|
154
|
+
begin
|
|
155
|
+
cursor = Cursor.new(stream, entry: @entry)
|
|
156
|
+
cursor.sheet = @sheets && @sheets[index]&.name
|
|
157
|
+
tag = table_at(cursor, index) or raise MalformedFileError, "#{@entry}: there is no sheet #{index + 1}"
|
|
158
|
+
cursor.sheet = sheet_name(tag, index)
|
|
159
|
+
bound = @zip.token_bound(@entry)
|
|
160
|
+
cut = row <= 1 ? Cut.new(DECLARATION + tag + cursor.rest, stream, 1, bound: bound) : seek_row(cursor, stream, tag, row, bound)
|
|
161
|
+
rescue IOError, SystemCallError => error
|
|
162
|
+
raise cursor_error(cursor, "cannot be read: #{error.message}"), cause: error
|
|
163
|
+
ensure
|
|
164
|
+
stream.close unless cut
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Runs the block on a cursor over a new stream of the entry, which is closed afterwards. The Cursor bounds what
|
|
171
|
+
# it buffers, so the stream does not bound texts by max_cell_bytes: a text too long is reported by the
|
|
172
|
+
# ContentParser of its sheet, at its row and column.
|
|
173
|
+
def walk
|
|
174
|
+
stream = @zip.stream(@entry, bound_tokens: false)
|
|
175
|
+
cursor = Cursor.new(stream, entry: @entry)
|
|
176
|
+
yield cursor
|
|
177
|
+
rescue IOError, SystemCallError => error
|
|
178
|
+
raise cursor_error(cursor, "cannot be read: #{error.message}"), cause: error
|
|
179
|
+
ensure
|
|
180
|
+
stream&.close
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def cursor_error(cursor, message)
|
|
184
|
+
sheet = cursor&.sheet
|
|
185
|
+
return MalformedFileError.new("#{@entry} #{message}") unless sheet
|
|
186
|
+
|
|
187
|
+
MalformedFileError.at("the sheet #{message}", sheet: sheet)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Reads the root start tag, and the namespace prefixes it declares the first time.
|
|
191
|
+
def read_root(cursor)
|
|
192
|
+
@prefixes ||= declared_prefixes(root(cursor, @entry), @entry)
|
|
193
|
+
@patterns ||= Patterns.new(@prefixes)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def root(cursor, entry)
|
|
197
|
+
cursor.next_tag(ROOT) or raise MalformedFileError, "#{entry}: the document has no root element"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @raise [MalformedFileError] for a prefix that is not an NCName of at most MAX_PREFIX_BYTES UTF-8 bytes
|
|
201
|
+
def declared_prefixes(tag, entry)
|
|
202
|
+
by_uri = {}
|
|
203
|
+
tag.scan(DECLARATIONS) do |prefix, double, single|
|
|
204
|
+
by_uri[double || single] ||= prefix ? valid_prefix(prefix, entry) : ""
|
|
205
|
+
end
|
|
206
|
+
NAMESPACES.to_h { |name, uri| [ name, by_uri.fetch(uri.b) { name.to_s.dup } ] }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def valid_prefix(raw, entry)
|
|
210
|
+
prefix = raw.dup.force_encoding(Encoding::UTF_8)
|
|
211
|
+
return prefix if prefix.bytesize <= MAX_PREFIX_BYTES && prefix.valid_encoding? && PREFIX.match?(prefix)
|
|
212
|
+
|
|
213
|
+
raise MalformedFileError, "#{entry}: the root element declares an invalid namespace prefix #{Untrusted.show(prefix)}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Yields the start tag of every table that is not inside another one.
|
|
217
|
+
def each_table(cursor)
|
|
218
|
+
tag = first_table(cursor)
|
|
219
|
+
depth = 0
|
|
220
|
+
while tag
|
|
221
|
+
if closing?(tag)
|
|
222
|
+
depth -= 1 if depth.positive?
|
|
223
|
+
else
|
|
224
|
+
yield tag if depth.zero?
|
|
225
|
+
depth += 1 unless self_closing?(tag)
|
|
226
|
+
end
|
|
227
|
+
tag = cursor.next_tag(@patterns.tables)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @return [String, nil] the start tag of the table at +index+
|
|
232
|
+
def table_at(cursor, index)
|
|
233
|
+
each_table(cursor) do |tag|
|
|
234
|
+
return tag if index.zero?
|
|
235
|
+
|
|
236
|
+
index -= 1
|
|
237
|
+
end
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Reads the root and the part before the first table, noting the hidden table styles and the cell formats
|
|
242
|
+
# the first time.
|
|
243
|
+
# @return [String, nil] the first table start tag
|
|
244
|
+
def first_table(cursor)
|
|
245
|
+
read_root(cursor)
|
|
246
|
+
return cursor.next_tag(@patterns.tables) if @hidden_styles
|
|
247
|
+
|
|
248
|
+
hidden = {}
|
|
249
|
+
formats = CellFormats.new
|
|
250
|
+
tag = StyleScan.new(cursor, @patterns, formats, hidden, max_bytes: MAX_HEAD_BYTES).scan do
|
|
251
|
+
"#{@entry}: the part before the first sheet is longer than #{MAX_HEAD_BYTES} bytes"
|
|
252
|
+
end
|
|
253
|
+
@hidden_styles = hidden
|
|
254
|
+
@head_formats = formats
|
|
255
|
+
tag
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The cell formats of the common styles, from the styles entry with its own prefixes.
|
|
259
|
+
def common_formats
|
|
260
|
+
stream = @zip.stream(@styles_entry)
|
|
261
|
+
cursor = Cursor.new(stream, entry: @styles_entry)
|
|
262
|
+
patterns = Patterns.new(declared_prefixes(root(cursor, @styles_entry), @styles_entry))
|
|
263
|
+
formats = CellFormats.new
|
|
264
|
+
StyleScan.new(cursor, patterns, formats, {}, max_bytes: MAX_HEAD_BYTES).scan { "#{@styles_entry} is longer than #{MAX_HEAD_BYTES} bytes" }
|
|
265
|
+
formats
|
|
266
|
+
rescue IOError, SystemCallError => error
|
|
267
|
+
raise MalformedFileError, "#{@styles_entry} cannot be read: #{error.message}"
|
|
268
|
+
ensure
|
|
269
|
+
stream&.close
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def sheet_info(tag, index)
|
|
273
|
+
style = attribute(tag, @patterns.table_style)
|
|
274
|
+
SheetInfo.new(name: sheet_name(tag, index), index: index, hidden: !style.nil? && @hidden_styles.key?(style))
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The references of a name are decoded as in the cells (References): an invalid one is refused.
|
|
278
|
+
def sheet_name(tag, index)
|
|
279
|
+
name = strict_attribute(tag, @patterns.table_name)
|
|
280
|
+
raise MalformedFileError, "#{@entry}: sheet #{index + 1} has no name" if name.nil?
|
|
281
|
+
raise MalformedFileError, "#{@entry}: the name of sheet #{index + 1} is not valid UTF-8" unless name.valid_encoding?
|
|
282
|
+
|
|
283
|
+
name
|
|
284
|
+
rescue References::Invalid => error
|
|
285
|
+
raise MalformedFileError, "#{@entry}: the name of sheet #{index + 1} holds #{error.message}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# The stream of the sheet from the row start tag holding +row+ (see RowSeek); the columns are kept when a cell
|
|
289
|
+
# format may come from their default cell style.
|
|
290
|
+
# @param bound [LimitedInflater::TokenBound, nil] for the Cut (ZipContainer#token_bound)
|
|
291
|
+
# @return [Cut, nil] nil when the table ends first
|
|
292
|
+
def seek_row(cursor, stream, table, row, bound)
|
|
293
|
+
return if self_closing?(table)
|
|
294
|
+
|
|
295
|
+
seek = RowSeek.new(cursor, @patterns, columns: !cell_formats.empty?, max_column_bytes: MAX_HEAD_BYTES)
|
|
296
|
+
head, first_row = seek.seek(row, DECLARATION + table)
|
|
297
|
+
Cut.new(head, stream, first_row, bound: bound) if head
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|