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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
require_relative "count"
|
|
5
|
+
require_relative "tag_text"
|
|
6
|
+
|
|
7
|
+
module Calcsheet
|
|
8
|
+
module Readers
|
|
9
|
+
module Ods
|
|
10
|
+
# Passes over the styles of an ODS entry with a Cursor, up to its first table or its end (the automatic styles
|
|
11
|
+
# of content.xml, the common styles of styles.xml), noting the table styles that hide a sheet and the cell and
|
|
12
|
+
# data styles that decide how a value reads (CellFormats): a date style that shows a time or none, and a plain
|
|
13
|
+
# zero-padding number style (+00000+).
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class StyleScan
|
|
17
|
+
include TagText
|
|
18
|
+
|
|
19
|
+
# The fields of a date style that show a time: a date value under it is a datetime.
|
|
20
|
+
TIME_FIELDS = %w[hours minutes seconds am-pm].freeze
|
|
21
|
+
|
|
22
|
+
# @param cursor [Cursor] at the start of the styles
|
|
23
|
+
# @param patterns [Patterns] of the entry's prefixes
|
|
24
|
+
# @param formats [CellFormats] where the cell and data styles go
|
|
25
|
+
# @param hidden [Hash{String => true}] where the names of the table styles with +table:display="false"+ go
|
|
26
|
+
# @param max_bytes [Integer] the most bytes of the entry the styles may take
|
|
27
|
+
def initialize(cursor, patterns, formats, hidden, max_bytes:)
|
|
28
|
+
@cursor = cursor
|
|
29
|
+
@patterns = patterns
|
|
30
|
+
@formats = formats
|
|
31
|
+
@hidden = hidden
|
|
32
|
+
@max_bytes = max_bytes
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @yieldreturn [String] the message of the error raised beyond +max_bytes+
|
|
36
|
+
# @return [String, nil] the first table start tag
|
|
37
|
+
# @raise [MalformedFileError] beyond +max_bytes+, or for an error of the cursor
|
|
38
|
+
def scan
|
|
39
|
+
style = nil
|
|
40
|
+
while (tag = @cursor.next_tag(@patterns.head))
|
|
41
|
+
raise MalformedFileError, yield if @cursor.position > @max_bytes
|
|
42
|
+
|
|
43
|
+
name = element_name(tag)
|
|
44
|
+
case name
|
|
45
|
+
when @patterns.table then return tag
|
|
46
|
+
when @patterns.style
|
|
47
|
+
closing = closing?(tag)
|
|
48
|
+
style = closing || self_closing?(tag) ? nil : table_style(tag)
|
|
49
|
+
cell_style(tag) unless closing
|
|
50
|
+
when @patterns.table_properties
|
|
51
|
+
@hidden[style] = true if style && attribute(tag, @patterns.display) == "false"
|
|
52
|
+
else data_style(tag, name)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Notes a cell style with its data style and its parent.
|
|
61
|
+
def cell_style(tag)
|
|
62
|
+
return unless attribute(tag, @patterns.family) == "table-cell"
|
|
63
|
+
|
|
64
|
+
name = attribute(tag, @patterns.style_name) or return
|
|
65
|
+
@formats.cell_style(name, attribute(tag, @patterns.data_style_name), attribute(tag, @patterns.parent_style_name))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Follows the elements of the data styles: number:date-style and number:number-style with their fields; a
|
|
69
|
+
# style:map inside a number style makes it conditional.
|
|
70
|
+
def data_style(tag, name)
|
|
71
|
+
closing = closing?(tag)
|
|
72
|
+
local = name.delete_prefix(@patterns.number_qualifier)
|
|
73
|
+
case local
|
|
74
|
+
when "date-style", "number-style"
|
|
75
|
+
return @formats.finish if closing
|
|
76
|
+
|
|
77
|
+
@formats.start(local == "date-style" ? :date : :number, attribute(tag, @patterns.style_name))
|
|
78
|
+
@formats.finish if self_closing?(tag)
|
|
79
|
+
else
|
|
80
|
+
return if closing || @formats.current.nil?
|
|
81
|
+
|
|
82
|
+
if @formats.current == :date
|
|
83
|
+
@formats.time! if TIME_FIELDS.include?(local)
|
|
84
|
+
elsif local == "number" && @formats.digits.nil? && @formats.plain?
|
|
85
|
+
@formats.number!(zero_padding(tag))
|
|
86
|
+
else
|
|
87
|
+
@formats.not_plain!
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Integer, nil] the minimum integer digits of a number:number without decimals or grouping
|
|
93
|
+
def zero_padding(tag)
|
|
94
|
+
decimals = attribute(tag, @patterns.decimal_places)
|
|
95
|
+
return if (decimals && decimals != "0") || attribute(tag, @patterns.grouping) == "true"
|
|
96
|
+
|
|
97
|
+
count = Count.parse(attribute(tag, @patterns.min_integer_digits))
|
|
98
|
+
count if count && count >= 2
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [String, nil] the name of a style of the table family
|
|
102
|
+
def table_style(tag)
|
|
103
|
+
attribute(tag, @patterns.family) == "table" ? attribute(tag, @patterns.style_name) : nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../references"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
module Readers
|
|
7
|
+
module Ods
|
|
8
|
+
# What the byte scanners of an ODS entry (Scanner, StyleScan, RowSeek) read of a tag they found: its name,
|
|
9
|
+
# whether it closes an element or is empty, and the decoded value of an attribute. Included, its methods are
|
|
10
|
+
# private helpers.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module TagText
|
|
14
|
+
# The qualified name of a start or end tag.
|
|
15
|
+
NAME = %r{\A</?([^\s/>]+)}n
|
|
16
|
+
# A reference in an attribute value: a predefined entity, a decimal or a hexadecimal character reference.
|
|
17
|
+
ENTITY = /&(?:(lt|gt|amp|quot|apos)|#([0-9]{1,7})|#x(\h{1,6}));/n
|
|
18
|
+
# The predefined entities of XML.
|
|
19
|
+
ENTITIES = { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => '"', "apos" => "'" }.freeze
|
|
20
|
+
# The byte of "/".
|
|
21
|
+
SLASH = 0x2F
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @return [String, nil] the qualified name of the element of +tag+
|
|
26
|
+
def element_name(tag)
|
|
27
|
+
tag[NAME, 1]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether +tag+ is an end tag ("</...").
|
|
31
|
+
def closing?(tag)
|
|
32
|
+
tag.getbyte(1) == SLASH
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Whether +tag+ is an empty-element tag ("<.../>").
|
|
36
|
+
def self_closing?(tag)
|
|
37
|
+
tag.getbyte(-2) == SLASH
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param pattern [Regexp] the attribute, as Patterns builds it
|
|
41
|
+
# @return [String, nil] the decoded UTF-8 value of the attribute
|
|
42
|
+
def attribute(tag, pattern)
|
|
43
|
+
match = pattern.match(tag) or return
|
|
44
|
+
decode(match[1] || match[2])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param pattern [Regexp] the attribute, as Patterns builds it
|
|
48
|
+
# @return [String, nil] the value of the attribute decoded as the cells are (References), tagged UTF-8 but not
|
|
49
|
+
# validated
|
|
50
|
+
# @raise [References::Invalid] for an invalid reference, which #attribute would keep as written
|
|
51
|
+
def strict_attribute(tag, pattern)
|
|
52
|
+
match = pattern.match(tag) or return
|
|
53
|
+
raw = match[1] || match[2]
|
|
54
|
+
(raw.include?("&") ? References.decode(raw) : raw.dup).force_encoding(Encoding::UTF_8)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [String] +raw+ with its references decoded, tagged UTF-8 but not validated (the caller checks what
|
|
58
|
+
# it keeps, such as a style name)
|
|
59
|
+
def decode(raw)
|
|
60
|
+
text = raw.include?("&") ? raw.gsub(ENTITY) { entity(Regexp.last_match) } : raw.dup
|
|
61
|
+
text.force_encoding(Encoding::UTF_8)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A character reference outside Unicode or of a surrogate is left as written.
|
|
65
|
+
def entity(match)
|
|
66
|
+
return ENTITIES.fetch(match[1]).b if match[1]
|
|
67
|
+
|
|
68
|
+
code = match[2] ? match[2].to_i : match[3].to_i(16)
|
|
69
|
+
return match[0] if code.zero? || code > 0x10FFFF || code.between?(0xD800, 0xDFFF)
|
|
70
|
+
|
|
71
|
+
[ code ].pack("U").b
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../zip_container"
|
|
5
|
+
require_relative "ods/scanner"
|
|
6
|
+
require_relative "ods/content_parser"
|
|
7
|
+
require_relative "row_batches"
|
|
8
|
+
|
|
9
|
+
module Calcsheet
|
|
10
|
+
module Readers
|
|
11
|
+
# The ODS reader backend (see Reading for the contract): the tables of +content.xml+. Every sheet lives in
|
|
12
|
+
# that one entry, so each read inflates it from the start: Scanner finds the table, and the first row wanted,
|
|
13
|
+
# by scanning bytes (sampling, +start_at:+), and ContentParser parses from there to the end of the table only.
|
|
14
|
+
# Sampling and the header check of merged sheets therefore parse the first rows of each sheet and nothing else.
|
|
15
|
+
# Every pass counts against +max_uncompressed_size+, and +max_sheets+ bounds the tables, hence the
|
|
16
|
+
# passes a merged read can make. The rows of a repeated row or cell share their value objects (see ContentParser).
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
class OdsReader
|
|
20
|
+
include RowBatches
|
|
21
|
+
|
|
22
|
+
# The entry that holds every table, and the one of the common styles.
|
|
23
|
+
CONTENT = "content.xml"
|
|
24
|
+
STYLES = "styles.xml"
|
|
25
|
+
|
|
26
|
+
# @return [Boolean] true: the zip central directory needs random access
|
|
27
|
+
def self.needs_seek?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param source [Source] a seekable source
|
|
32
|
+
# @param options [Options] the book options; the zip limits come from them
|
|
33
|
+
# @raise [MalformedFileError] when the archive cannot be read or has no content.xml
|
|
34
|
+
# @raise [ZipBombError] when a zip limit is exceeded
|
|
35
|
+
def initialize(source, options)
|
|
36
|
+
@zip = ZipContainer.open(source, options)
|
|
37
|
+
raise MalformedFileError, "the spreadsheet has no #{CONTENT}" unless @zip.entry?(CONTENT)
|
|
38
|
+
|
|
39
|
+
@scanner = Ods::Scanner.new(@zip, entry: CONTENT, styles: (STYLES if @zip.entry?(STYLES)), max_sheets: options.max_sheets)
|
|
40
|
+
@max_cell_bytes = options.max_cell_bytes
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Scans the whole content.xml the first time.
|
|
44
|
+
# @return [Array<SheetInfo>]
|
|
45
|
+
# @raise [MalformedFileError] for a damaged content.xml
|
|
46
|
+
# @raise [TooManySheetsError] for more tables than +max_sheets+
|
|
47
|
+
def sheets
|
|
48
|
+
@scanner.sheets
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Holds nothing between reads: each read closes its own stream.
|
|
52
|
+
def close
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def each_row(index, cast, types, start_row, &block)
|
|
59
|
+
sheet = sheets.fetch(index)
|
|
60
|
+
io = @scanner.io_at_sheet(index, row: start_row) or return
|
|
61
|
+
begin
|
|
62
|
+
Ods::ContentParser.parse(io, sheet: sheet.name, prefixes: @scanner.prefixes, cast: cast, types: types,
|
|
63
|
+
first_row: io.first_row, start_row: start_row, formats: @scanner.cell_formats,
|
|
64
|
+
max_cell_bytes: @max_cell_bytes, &block)
|
|
65
|
+
ensure
|
|
66
|
+
io.close
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ox"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module Calcsheet
|
|
7
|
+
module Readers
|
|
8
|
+
# What the Ox parsers of the readers raise instead of Ox's own errors.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module OxErrors
|
|
12
|
+
# What Ox raises: an Ox::Error for damaged XML, a TypeError caused by what the IO raised, and an EncodingError
|
|
13
|
+
# for an element or attribute name that is not valid UTF-8.
|
|
14
|
+
RAISED = [ TypeError, Ox::Error, EncodingError ].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Raises the error of the stream itself when it is a Calcsheet::Error (a zip limit), else the
|
|
19
|
+
# MalformedFileError the block builds from the message of the original error (the IO's, else Ox's), with that
|
|
20
|
+
# error as +cause+.
|
|
21
|
+
#
|
|
22
|
+
# @param error [Exception] one of RAISED
|
|
23
|
+
# @yieldparam message [String]
|
|
24
|
+
# @yieldreturn [MalformedFileError]
|
|
25
|
+
def raise_translated(error)
|
|
26
|
+
cause = error.cause
|
|
27
|
+
raise cause if cause.is_a?(Calcsheet::Error)
|
|
28
|
+
|
|
29
|
+
original = cause || error
|
|
30
|
+
raise yield(original.message), cause: original
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Ox::ParseError] the +cause+ of the MalformedFileError of an error Ox reports to a SAX handler's
|
|
34
|
+
# +error+ callback instead of raising it
|
|
35
|
+
def parse_error(message, line, column)
|
|
36
|
+
Ox::ParseError.new("#{message} at line #{line}, column #{column}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Calcsheet
|
|
4
|
+
module Readers
|
|
5
|
+
# The XML references of a text (+&+, +A+, +A+), decoded by the gem instead of Ox for the XLSX and
|
|
6
|
+
# ODS parsers, and the line ends Ox hands over as written (#line_ends): Ox truncates a text at +�+ and writes a reference beyond U+10FFFF as another character, so that
|
|
7
|
+
# a crafted cell would read as a shorter or different text. A reference to NUL, a surrogate or a code point beyond
|
|
8
|
+
# U+10FFFF, an unknown entity and an "&" that starts no reference raise Invalid.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module References
|
|
12
|
+
# Raised for a text that is not valid XML; the parser turns it into a MalformedFileError at its cell.
|
|
13
|
+
class Invalid < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Leading zeros are allowed ("A"); the digits after them are bounded, which keeps the codes small.
|
|
16
|
+
REFERENCE = /&(?:(lt|gt|amp|quot|apos)|#0*([0-9]{1,7})|#x0*(\h{1,6}));/n
|
|
17
|
+
# The predefined entities of XML.
|
|
18
|
+
ENTITIES = { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => '"', "apos" => "'" }.transform_values { _1.b.freeze }.freeze
|
|
19
|
+
# The code points a reference may name: up to U+10FFFF, not a surrogate, not NUL.
|
|
20
|
+
MAX_CODE_POINT = 0x10FFFF
|
|
21
|
+
SURROGATES = (0xD800..0xDFFF)
|
|
22
|
+
|
|
23
|
+
# The line ends that XML reads as LF (XML 1.0, 2.11): CR LF and a CR alone.
|
|
24
|
+
LINE_END = /\r\n?/n
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Reads the line ends of a text as XML does; called before #decode, so that a referenced CR (+ +) stays.
|
|
29
|
+
# @param text [String] text holding a CR
|
|
30
|
+
# @return [String] a new String in the encoding of +text+
|
|
31
|
+
def line_ends(text)
|
|
32
|
+
text.b.gsub(LINE_END, "\n").force_encoding(text.encoding)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Works on the bytes, so that a text that is not valid UTF-8 is decoded too (and refused later by its reader).
|
|
36
|
+
# @param text [String] text holding an "&"
|
|
37
|
+
# @return [String] a new String in the encoding of +text+, with the references decoded
|
|
38
|
+
# @raise [Invalid]
|
|
39
|
+
def decode(text)
|
|
40
|
+
count = 0
|
|
41
|
+
bytes = text.b
|
|
42
|
+
decoded = bytes.gsub(REFERENCE) do
|
|
43
|
+
count += 1
|
|
44
|
+
match = Regexp.last_match
|
|
45
|
+
match[1] ? ENTITIES.fetch(match[1]) : character(match[2] ? match[2].to_i : match[3].hex, match[0])
|
|
46
|
+
end
|
|
47
|
+
raise Invalid, "an \"&\" that starts no character reference" unless count == bytes.count("&")
|
|
48
|
+
|
|
49
|
+
decoded.force_encoding(text.encoding)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def character(code, reference)
|
|
53
|
+
if code.zero? || code > MAX_CODE_POINT || SURROGATES.cover?(code)
|
|
54
|
+
raise Invalid, "the invalid character reference #{reference}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
code.chr(Encoding::UTF_8).b
|
|
58
|
+
end
|
|
59
|
+
private_class_method :character
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Calcsheet
|
|
4
|
+
module Readers
|
|
5
|
+
# The +first_rows+ and +each_raw_batch+ of the backend contract (see Reading) for a reader that streams the rows of
|
|
6
|
+
# a sheet with a private +each_row(sheet_index, cast, types, start_row) { |row| ... }+; a throw or a break out of
|
|
7
|
+
# the block must stop the stream and release it.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module RowBatches
|
|
11
|
+
# Thrown out of +each_row+ once #first_rows has its rows; the reader's +ensure+ releases the stream.
|
|
12
|
+
ENOUGH_ROWS = :calcsheet_enough_rows
|
|
13
|
+
|
|
14
|
+
# @return [Array<RawRow>] the first +count+ non-blank rows of the sheet
|
|
15
|
+
def first_rows(sheet_index, count, cast:, types: nil)
|
|
16
|
+
rows = []
|
|
17
|
+
return rows unless count.positive?
|
|
18
|
+
|
|
19
|
+
# The throw leaves through the reader's parser (Ox re-raises it, see SaxSheet#parse) and the +ensure+ of its
|
|
20
|
+
# +each_row+, which closes the stream.
|
|
21
|
+
catch(ENOUGH_ROWS) do
|
|
22
|
+
each_row(sheet_index, cast, types, 1) do |row|
|
|
23
|
+
rows << row
|
|
24
|
+
throw ENOUGH_ROWS if rows.size == count
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
rows
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @yieldparam batch [Array<RawRow>] at most +size+ rows numbered +start_row+ or later, a new Array each time
|
|
31
|
+
# @return [void]
|
|
32
|
+
def each_raw_batch(sheet_index, size:, cast:, types:, start_row: 1)
|
|
33
|
+
batch = []
|
|
34
|
+
each_row(sheet_index, cast, types, start_row) do |row|
|
|
35
|
+
batch << row
|
|
36
|
+
next if batch.size < size
|
|
37
|
+
|
|
38
|
+
yield batch
|
|
39
|
+
batch = []
|
|
40
|
+
end
|
|
41
|
+
yield batch unless batch.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ox"
|
|
4
|
+
require_relative "../cast"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../limited_inflater"
|
|
7
|
+
require_relative "../raw_row"
|
|
8
|
+
require_relative "../reading"
|
|
9
|
+
require_relative "ox_errors"
|
|
10
|
+
|
|
11
|
+
module Calcsheet
|
|
12
|
+
module Readers
|
|
13
|
+
# 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.
|
|
15
|
+
#
|
|
16
|
+
# 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.
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
module SaxSheet
|
|
23
|
+
# Thrown by the handler where the part it parses ends, so that the rest is never read.
|
|
24
|
+
STOP = :calcsheet_end_of_sheet
|
|
25
|
+
|
|
26
|
+
# @api private
|
|
27
|
+
# The throw of STOP, like an exception of a callback, unwinds through Ox.sax_parse: Ox runs the parse under
|
|
28
|
+
# rb_protect, frees its buffers and re-raises (rb_jump_tag), so nothing of the parser is left behind and the
|
|
29
|
+
# caller's +ensure+ closes the stream.
|
|
30
|
+
def parse(io)
|
|
31
|
+
catch(STOP) { Ox.sax_parse(self, io, **self.class::OPTIONS) }
|
|
32
|
+
nil
|
|
33
|
+
rescue *OxErrors::RAISED => error
|
|
34
|
+
# A TypeError of the caller's block, or one without a cause, is not the stream's.
|
|
35
|
+
raise if @yielding || (error.is_a?(TypeError) && error.cause.nil?)
|
|
36
|
+
raise token_too_long(error.cause), cause: error.cause if error.cause.is_a?(LimitedInflater::TokenTooLong)
|
|
37
|
+
|
|
38
|
+
OxErrors.raise_translated(error) { malformed("the sheet cannot be read: #{_1}") }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Ox reports damaged XML here instead of raising.
|
|
42
|
+
# @raise [MalformedFileError] located at the row and column being read
|
|
43
|
+
def error(message, line, column)
|
|
44
|
+
raise malformed(message), cause: OxErrors.parse_error(message, line, column)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
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
|
|
73
|
+
# them.
|
|
74
|
+
def yield_row(number, values, errors)
|
|
75
|
+
row = RawRow.new(number: number, values: values, errors: errors)
|
|
76
|
+
@yielding = true
|
|
77
|
+
@on_row.call(row)
|
|
78
|
+
@yielding = false
|
|
79
|
+
end
|
|
80
|
+
|
|
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
|
+
# The zip stream refused a text or a tag too long for max_cell_bytes: located like the parser's own errors.
|
|
98
|
+
def token_too_long(error)
|
|
99
|
+
malformed("a text or a tag is longer than max_cell_bytes (#{error.max_cell_bytes} bytes) allows",
|
|
100
|
+
column_index: (current_column if @in_cell))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def malformed(message, column_index: nil, row: current_row)
|
|
104
|
+
MalformedFileError.at(message, sheet: @sheet, row: row, column_index: column_index)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tag_scan"
|
|
4
|
+
|
|
5
|
+
module Calcsheet
|
|
6
|
+
module Readers
|
|
7
|
+
# The rest of a stream after a byte scanner (Xlsx::RowSkipper, Ods::Scanner) found where the parser starts: the
|
|
8
|
+
# bytes already read (+head+), then the stream. Answers +readpartial+ (what Ox uses), +read+ and +eof?+; never
|
|
9
|
+
# hands out more bytes than asked.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class StreamRemainder
|
|
13
|
+
# @param head [String] binary bytes handed out first
|
|
14
|
+
# @param io [#readpartial] the rest; never closed here
|
|
15
|
+
# @param bound [LimitedInflater::TokenBound, nil] checks every piece handed out, when +io+ is a stream
|
|
16
|
+
# opened without the bound because a byte scanner read it first (ZipContainer#token_bound)
|
|
17
|
+
def initialize(head, io, bound: nil)
|
|
18
|
+
@head = head
|
|
19
|
+
@io = io
|
|
20
|
+
@bound = bound
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @raise [EOFError] at the end
|
|
24
|
+
# @raise [LimitedInflater::TokenTooLong] when the bound is exceeded
|
|
25
|
+
def readpartial(length, outbuf = nil)
|
|
26
|
+
data = @head.empty? ? @io.readpartial(length) : take(length)
|
|
27
|
+
@bound&.check(data)
|
|
28
|
+
outbuf ? outbuf.replace(data) : data
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String, nil] up to +length+ bytes (all with nil); nil at the end for a positive +length+
|
|
32
|
+
# @raise [LimitedInflater::TokenTooLong] when the bound is exceeded
|
|
33
|
+
def read(length = nil, outbuf = nil)
|
|
34
|
+
data = String.new(encoding: Encoding::BINARY)
|
|
35
|
+
begin
|
|
36
|
+
data << readpartial(length ? length - data.bytesize : TagScan::CHUNK_BYTES) while length.nil? || data.bytesize < length
|
|
37
|
+
rescue EOFError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
data = nil if data.empty? && length&.positive?
|
|
41
|
+
outbuf && data ? outbuf.replace(data) : data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reads ahead one chunk into the head to find out.
|
|
45
|
+
# @return [Boolean] whether nothing is left
|
|
46
|
+
def eof?
|
|
47
|
+
return false unless @head.empty?
|
|
48
|
+
|
|
49
|
+
@head = @io.readpartial(TagScan::CHUNK_BYTES).b
|
|
50
|
+
false
|
|
51
|
+
rescue EOFError
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def take(length)
|
|
58
|
+
return @head.tap { @head = String.new(encoding: Encoding::BINARY) } if @head.bytesize <= length
|
|
59
|
+
|
|
60
|
+
piece = @head.byteslice(0, length)
|
|
61
|
+
@head = @head.byteslice(length, @head.bytesize - length)
|
|
62
|
+
piece
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|