calcsheet-native 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.
@@ -0,0 +1,370 @@
1
+ //! The plain subset of XML the sheet readers handle: elements, attributes and text, a leading XML declaration, well
2
+ //! formed. Anything else (a comment, CDATA, a DOCTYPE, another processing instruction, a reference, "<" or a line
3
+ //! break in an attribute value, an odd name, a tag longer than calcsheet's scanners accept) is `Unsupported`, and
4
+ //! the Ruby side then reads the sheet with calcsheet's own parser.
5
+
6
+ use memchr::memchr;
7
+
8
+ /// TagScan::MAX_TAG_BYTES: calcsheet's scanners refuse a longer tag before the row a read starts from.
9
+ const MAX_TAG_BYTES: usize = 65_536;
10
+ /// Deeper XML is left to the Ruby parser.
11
+ const MAX_DEPTH: usize = 64;
12
+
13
+ /// Why the subset reader gives up: the Ruby parser decides.
14
+ pub struct Unsupported;
15
+
16
+ pub type Result<T> = std::result::Result<T, Unsupported>;
17
+
18
+ pub enum Item {
19
+ /// A start tag: the span of its raw name in the buffer, and true for an empty element (`<a/>`), which ends right
20
+ /// after. Its attributes are in `Lexer::attrs` until the next item.
21
+ Start(usize, usize, bool),
22
+ /// An end tag: the span of its raw name.
23
+ End(usize, usize),
24
+ Text(usize, usize),
25
+ }
26
+
27
+ pub struct Lexer {
28
+ pub buf: Vec<u8>,
29
+ pub pos: usize,
30
+ pub eof: bool,
31
+ seen_root: bool,
32
+ started: bool,
33
+ // The raw names of the open elements, end to end.
34
+ stack: Vec<u8>,
35
+ stack_ends: Vec<usize>,
36
+ /// The attributes of the start tag just read: name and value spans in `buf`.
37
+ pub attrs: Vec<(usize, usize, usize, usize)>,
38
+ // The end tag just read: where it starts and its name, for #unread_end.
39
+ last_end: Option<(usize, usize, usize)>,
40
+ }
41
+
42
+ impl Lexer {
43
+ pub fn new() -> Self {
44
+ Lexer {
45
+ buf: Vec::new(),
46
+ pos: 0,
47
+ eof: false,
48
+ seen_root: false,
49
+ started: false,
50
+ stack: Vec::new(),
51
+ stack_ends: Vec::new(),
52
+ attrs: Vec::new(),
53
+ last_end: None,
54
+ }
55
+ }
56
+
57
+ /// Appends text, dropping what was read. Spans of earlier items are invalid afterwards.
58
+ pub fn feed(&mut self, chunk: &[u8]) {
59
+ if self.pos > 0 {
60
+ self.buf.drain(..self.pos);
61
+ self.pos = 0;
62
+ }
63
+ self.buf.extend_from_slice(chunk);
64
+ }
65
+
66
+ /// Whether the text ends after a complete document, as an Ox parse that meets no end of its own does.
67
+ pub fn complete(&self) -> bool {
68
+ self.stack_ends.is_empty()
69
+ && self.seen_root
70
+ && self.buf[self.pos..].iter().all(u8::is_ascii_whitespace)
71
+ }
72
+
73
+ /// The next item, `None` when the text fed so far ends inside it.
74
+ pub fn item(&mut self) -> Result<Option<Item>> {
75
+ loop {
76
+ let n = self.buf.len();
77
+ let start = self.pos;
78
+ if start == n {
79
+ return Ok(None);
80
+ }
81
+ if self.buf[start] != b'<' {
82
+ let end = memchr(b'<', &self.buf[start..]).map_or(n, |k| start + k);
83
+ if end == n && !self.eof {
84
+ return Ok(None);
85
+ }
86
+ if self.stack_ends.is_empty()
87
+ && !self.buf[start..end].iter().all(u8::is_ascii_whitespace)
88
+ {
89
+ return Err(Unsupported);
90
+ }
91
+ self.pos = end;
92
+ self.started = true;
93
+ return Ok(Some(Item::Text(start, end)));
94
+ }
95
+ let Some(close) = memchr(b'>', &self.buf[start..]).map(|k| start + k) else {
96
+ return if self.eof || n - start >= MAX_TAG_BYTES {
97
+ Err(Unsupported)
98
+ } else {
99
+ Ok(None)
100
+ };
101
+ };
102
+ if close - start >= MAX_TAG_BYTES {
103
+ return Err(Unsupported);
104
+ }
105
+ match self.buf.get(start + 1).copied() {
106
+ Some(b'?') => {
107
+ // Only the XML declaration, first in the document.
108
+ let tag = &self.buf[start + 1..close];
109
+ if self.started || !tag.starts_with(b"?xml ") || !tag.ends_with(b"?") {
110
+ return Err(Unsupported);
111
+ }
112
+ self.started = true;
113
+ self.pos = close + 1;
114
+ }
115
+ Some(b'!') => return Err(Unsupported),
116
+ Some(b'/') => {
117
+ let name_start = start + 2;
118
+ let name_end = name_start + trim_end(&self.buf[name_start..close]).len();
119
+ let open_start = match self.stack_ends.len() {
120
+ 0 => return Err(Unsupported),
121
+ 1 => 0,
122
+ depth => self.stack_ends[depth - 2],
123
+ };
124
+ if self.stack[open_start..] != self.buf[name_start..name_end] {
125
+ return Err(Unsupported);
126
+ }
127
+ self.stack.truncate(open_start);
128
+ self.stack_ends.pop();
129
+ self.started = true;
130
+ self.pos = close + 1;
131
+ self.last_end = Some((start, name_start, name_end));
132
+ return Ok(Some(Item::End(name_start, name_end)));
133
+ }
134
+ _ => {
135
+ let empty = self.buf[close - 1] == b'/' && close - 1 > start;
136
+ let body_end = if empty { close - 1 } else { close };
137
+ let name_start = start + 1;
138
+ let name_end = (name_start..body_end)
139
+ .find(|&i| is_space(self.buf[i]))
140
+ .unwrap_or(body_end);
141
+ name(&self.buf[name_start..name_end])?;
142
+ self.attrs.clear();
143
+ attributes(&self.buf, name_end, body_end, &mut self.attrs)?;
144
+ if empty {
145
+ if self.stack_ends.is_empty() {
146
+ return Err(Unsupported);
147
+ }
148
+ } else {
149
+ if self.stack_ends.len() >= MAX_DEPTH {
150
+ return Err(Unsupported);
151
+ }
152
+ self.stack
153
+ .extend_from_slice(&self.buf[name_start..name_end]);
154
+ self.stack_ends.push(self.stack.len());
155
+ }
156
+ self.seen_root = true;
157
+ self.started = true;
158
+ self.pos = close + 1;
159
+ return Ok(Some(Item::Start(name_start, name_end, empty)));
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ /// Reads the end tag just read again at the next #item: its element is open again.
166
+ pub fn unread_end(&mut self) {
167
+ if let Some((start, name_start, name_end)) = self.last_end.take() {
168
+ self.stack
169
+ .extend_from_slice(&self.buf[name_start..name_end]);
170
+ self.stack_ends.push(self.stack.len());
171
+ self.pos = start;
172
+ }
173
+ }
174
+
175
+ /// The attributes of the start tag just read, refused when one is repeated (Ox would read them its own way).
176
+ pub fn check_unique(&self) -> Result<()> {
177
+ for (index, &(start, end, _, _)) in self.attrs.iter().enumerate() {
178
+ let name = &self.buf[start..end];
179
+ if self.attrs[..index]
180
+ .iter()
181
+ .any(|&(s, e, _, _)| &self.buf[s..e] == name)
182
+ {
183
+ return Err(Unsupported);
184
+ }
185
+ }
186
+ Ok(())
187
+ }
188
+ }
189
+
190
+ pub fn is_space(c: u8) -> bool {
191
+ matches!(c, b' ' | b'\t' | b'\n' | b'\r')
192
+ }
193
+
194
+ fn trim_end(bytes: &[u8]) -> &[u8] {
195
+ let end = bytes
196
+ .iter()
197
+ .rposition(|&c| !is_space(c))
198
+ .map_or(0, |k| k + 1);
199
+ &bytes[..end]
200
+ }
201
+
202
+ /// The bytes of a name, and those an attribute value of the subset may not hold, as lookup tables: one load per
203
+ /// byte instead of a chain of comparisons.
204
+ const NAME_BYTES: [bool; 256] =
205
+ byte_table(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-.:");
206
+ const NOT_IN_VALUES: [bool; 256] = byte_table(b"<&\r\n\t");
207
+
208
+ const fn byte_table(bytes: &[u8]) -> [bool; 256] {
209
+ let mut table = [false; 256];
210
+ let mut index = 0;
211
+ while index < bytes.len() {
212
+ table[bytes[index] as usize] = true;
213
+ index += 1;
214
+ }
215
+ table
216
+ }
217
+
218
+ /// A name of ASCII letters, digits, "_", "-", "." and at most one ":" between a prefix and a local name.
219
+ pub fn name(raw: &[u8]) -> Result<()> {
220
+ let Some(&first) = raw.first() else {
221
+ return Err(Unsupported);
222
+ };
223
+ if !(first.is_ascii_alphabetic() || first == b'_') {
224
+ return Err(Unsupported);
225
+ }
226
+ let mut colon = None;
227
+ for (index, &c) in raw.iter().enumerate() {
228
+ if !NAME_BYTES[c as usize] {
229
+ return Err(Unsupported);
230
+ }
231
+ if c == b':' {
232
+ if colon.is_some() {
233
+ return Err(Unsupported);
234
+ }
235
+ colon = Some(index);
236
+ }
237
+ }
238
+ match colon {
239
+ Some(index) if index + 1 == raw.len() => Err(Unsupported),
240
+ _ => Ok(()),
241
+ }
242
+ }
243
+
244
+ /// The name without its namespace prefix, as Ox's `strip_namespace` gives it.
245
+ pub fn local(raw: &[u8]) -> &[u8] {
246
+ memchr(b':', raw).map_or(raw, |colon| &raw[colon + 1..])
247
+ }
248
+
249
+ /// The attributes of a start tag in `buf[from..to]`: `name="value"` or `name='value'`, separated by white space,
250
+ /// the values without "<", "&", line breaks or tabs, which Ox would read in ways of its own. Spans are appended to
251
+ /// `attrs`.
252
+ fn attributes(
253
+ buf: &[u8],
254
+ mut at: usize,
255
+ to: usize,
256
+ attrs: &mut Vec<(usize, usize, usize, usize)>,
257
+ ) -> Result<()> {
258
+ loop {
259
+ let skipped = (at..to).find(|&i| !is_space(buf[i])).unwrap_or(to);
260
+ if skipped == to {
261
+ return Ok(());
262
+ }
263
+ if skipped == at {
264
+ return Err(Unsupported);
265
+ }
266
+ at = skipped;
267
+ let eq = memchr(b'=', &buf[at..to])
268
+ .map(|k| at + k)
269
+ .ok_or(Unsupported)?;
270
+ name(&buf[at..eq])?;
271
+ let quote = *buf.get(eq + 1).filter(|_| eq + 1 < to).ok_or(Unsupported)?;
272
+ if quote != b'"' && quote != b'\'' {
273
+ return Err(Unsupported);
274
+ }
275
+ let value_start = eq + 2;
276
+ let value_end = memchr(quote, &buf[value_start..to])
277
+ .map(|k| value_start + k)
278
+ .ok_or(Unsupported)?;
279
+ let value = &buf[value_start..value_end];
280
+ if value.iter().any(|&c| NOT_IN_VALUES[c as usize]) {
281
+ return Err(Unsupported);
282
+ }
283
+ attrs.push((at, eq, value_start, value_end));
284
+ at = value_end + 1;
285
+ }
286
+ }
287
+
288
+ /// Valid UTF-8, or unsupported.
289
+ pub fn utf8(bytes: &[u8]) -> Result<&[u8]> {
290
+ std::str::from_utf8(bytes)
291
+ .map(|_| bytes)
292
+ .map_err(|_| Unsupported)
293
+ }
294
+
295
+ /// References.decode: every "&" starts a reference to a predefined entity or to a code point other than NUL, a
296
+ /// surrogate or one beyond U+10FFFF; anything else is left to calcsheet, which refuses it.
297
+ pub fn decode(text: &[u8]) -> Result<Vec<u8>> {
298
+ let mut decoded = Vec::with_capacity(text.len());
299
+ let mut at = 0;
300
+ while let Some(offset) = memchr(b'&', &text[at..]) {
301
+ let amp = at + offset;
302
+ decoded.extend_from_slice(&text[at..amp]);
303
+ let rest = &text[amp + 1..];
304
+ let semicolon = memchr(b';', rest).ok_or(Unsupported)?;
305
+ let body = &rest[..semicolon];
306
+ match body {
307
+ b"lt" => decoded.push(b'<'),
308
+ b"gt" => decoded.push(b'>'),
309
+ b"amp" => decoded.push(b'&'),
310
+ b"quot" => decoded.push(b'"'),
311
+ b"apos" => decoded.push(b'\''),
312
+ _ => {
313
+ let code = reference(body).ok_or(Unsupported)?;
314
+ let character = char::from_u32(code)
315
+ .filter(|_| code != 0)
316
+ .ok_or(Unsupported)?;
317
+ let mut bytes = [0; 4];
318
+ decoded.extend_from_slice(character.encode_utf8(&mut bytes).as_bytes());
319
+ }
320
+ }
321
+ at = amp + 1 + semicolon + 1;
322
+ }
323
+ decoded.extend_from_slice(&text[at..]);
324
+ Ok(decoded)
325
+ }
326
+
327
+ /// The code point of `#0*[0-9]{1,7}` or `#x0*[0-9a-fA-F]{1,6}`.
328
+ fn reference(body: &[u8]) -> Option<u32> {
329
+ let (digits, radix, most) = match body {
330
+ [b'#', b'x', rest @ ..] => (rest, 16, 6),
331
+ [b'#', rest @ ..] => (rest, 10, 7),
332
+ _ => return None,
333
+ };
334
+ let significant = &digits[digits
335
+ .iter()
336
+ .position(|&c| c != b'0')
337
+ .unwrap_or(digits.len().saturating_sub(1))..];
338
+ if digits.is_empty() || significant.is_empty() || significant.len() > most {
339
+ return None;
340
+ }
341
+ let text = std::str::from_utf8(significant).ok()?;
342
+ if !text.bytes().all(|c| {
343
+ if radix == 16 {
344
+ c.is_ascii_hexdigit()
345
+ } else {
346
+ c.is_ascii_digit()
347
+ }
348
+ }) {
349
+ return None;
350
+ }
351
+ u32::from_str_radix(text, radix).ok()
352
+ }
353
+
354
+ /// References.line_ends: CR LF and a CR alone read as LF.
355
+ pub fn line_ends(text: &[u8]) -> Vec<u8> {
356
+ let mut lines = Vec::with_capacity(text.len());
357
+ let mut index = 0;
358
+ while index < text.len() {
359
+ if text[index] == b'\r' {
360
+ lines.push(b'\n');
361
+ if text.get(index + 1) == Some(&b'\n') {
362
+ index += 1;
363
+ }
364
+ } else {
365
+ lines.push(text[index]);
366
+ }
367
+ index += 1;
368
+ }
369
+ lines
370
+ }
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Calcsheet
6
+ module Native
7
+ # The native CSV backend: calcsheet's Ruby reader (dialect detection, encodings, numbering, checks and the
8
+ # conversion of the fields) with the records tokenized in Rust instead of by the stdlib CSV parser. A dialect the
9
+ # tokenizer does not handle (a separator or quote that is not one ASCII character, or a line ending the sample
10
+ # did not show) is parsed by the stdlib, as the Ruby backend does.
11
+ #
12
+ # It depends on the private methods of Calcsheet::Readers::CsvReader of the calcsheet minor version it supports.
13
+ class CsvReader < Calcsheet::Readers::CsvReader
14
+ # The line endings the tokenizer handles.
15
+ ROW_SEPARATORS = [ "\n", "\r", "\r\n" ].freeze
16
+
17
+ # The stdlib CSV parser's place for the reader: CsvReader#shift pulls records with +each+ and +next+, and
18
+ # reads +row_sep+ when a record is malformed.
19
+ class Records
20
+ # Records parsed per call into Rust.
21
+ BATCH = 1024
22
+ EMPTY = [].freeze
23
+
24
+ # @return [String] the line ending
25
+ attr_reader :row_sep
26
+ # @return [Integer] the physical lines the last record ends
27
+ attr_reader :breaks
28
+ # @return [Boolean] whether the last record has the stray line break outside quoted fields
29
+ attr_reader :stray
30
+
31
+ # @param input [Calcsheet::Readers::CsvInput]
32
+ # @param dialect [Calcsheet::Readers::CsvDialect::Dialect]
33
+ def initialize(input, dialect)
34
+ @input = input
35
+ @row_sep = dialect.row_sep
36
+ @tokenizer = CsvTokenizer.new(dialect.col_sep, dialect.quote_char, dialect.row_sep)
37
+ @fields = @all_breaks = @strays = EMPTY
38
+ @index = 0
39
+ @breaks = 0
40
+ @stray = false
41
+ end
42
+
43
+ # @return [Records] self: #next gives the records
44
+ def each
45
+ self
46
+ end
47
+
48
+ # @return [Array<String, nil>] the fields of the next record
49
+ # @raise [StopIteration] at the end
50
+ # @raise [CSV::MalformedCSVError] for a malformed record
51
+ # @raise [EncodingError] from the input, at the record that holds the invalid bytes
52
+ def next
53
+ if @index == @fields.size
54
+ @fields, @all_breaks, @strays = batch
55
+ @index = 0
56
+ raise StopIteration if @fields.empty?
57
+ end
58
+ index = @index
59
+ @index += 1
60
+ @breaks = @all_breaks[index]
61
+ @stray = @strays[index]
62
+ @fields[index]
63
+ end
64
+
65
+ private
66
+
67
+ # The next records, reading the input only when the text read ends inside a record.
68
+ def batch
69
+ loop do
70
+ records = @tokenizer.records(BATCH)
71
+ return records if records
72
+ raise CSV::MalformedCSVError.new(*@tokenizer.error) if records == false
73
+
74
+ chunk = @input.gets(nil, Calcsheet::Readers::CsvInput::CHUNK_SIZE)
75
+ chunk ? @tokenizer.feed(chunk) : @tokenizer.finish
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ # Parses with the tokenizer when it handles the dialect.
83
+ def restart(forced)
84
+ super
85
+ @csv = Records.new(@input, @dialect) if tokenizes?(@dialect)
86
+ end
87
+
88
+ # The text the parser reads, kept for the tokenizer.
89
+ def input
90
+ @input = super
91
+ end
92
+
93
+ def tokenizes?(dialect)
94
+ ROW_SEPARATORS.include?(dialect.row_sep) && [ dialect.col_sep, dialect.quote_char ].all? { _1.bytesize == 1 }
95
+ end
96
+
97
+ # CsvReader#each_row, with the line breaks and the stray line break of each record from the tokenizer instead
98
+ # of its raw text.
99
+ def each_row(sheet_index, cast, types, start, &block)
100
+ forced = forced_dialect(cast.options)
101
+ restart(forced) if @csv.nil? || start <= @records || forced != @forced
102
+ return super unless @csv.is_a?(Records)
103
+
104
+ strip = cast.options.strip_formula_prefix
105
+ builder = RowBuilder.new(cast, types)
106
+ while (fields = shift)
107
+ number = @records += 1
108
+ line = @line
109
+ @line += @csv.breaks
110
+ mixed_line_endings(number, line, @csv.row_sep) if @csv.stray
111
+ next if number < start
112
+
113
+ last = fields.size - 1
114
+ last -= 1 while last >= 0 && blank_field?(fields[last], number, last)
115
+ next if last.negative?
116
+
117
+ yield raw_row(builder, number, line, fields, last, strip)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ module Native
5
+ # The native ODS backend: calcsheet's Ruby reader (the zip archive and its limits, the Scanner that finds the
6
+ # tables, the rows of +start_at:+ and the cell styles) with the XML of each table read in Rust instead of by Ox.
7
+ # The Rust side reads a plain subset of XML and the values calcsheet accepts; on anything else (a comment, CDATA,
8
+ # a reference in an attribute, a value calcsheet refuses, an error of the zip stream...) the table is read again
9
+ # by calcsheet's own parser, which gives its exact rows or error. The rows already yielded are not yielded again,
10
+ # and the inflated total of the container and what the Scanner learned are set back to their state before the
11
+ # first attempt, so that the second one charges and caches exactly as calcsheet does.
12
+ #
13
+ # It depends on the private methods of Calcsheet::Readers::OdsReader and the internals of ZipContainer and
14
+ # Ods::Scanner of the calcsheet minor version it supports.
15
+ class OdsReader < Calcsheet::Readers::OdsReader
16
+ # Thrown when calcsheet's parser must read the table.
17
+ FALLBACK = :calcsheet_native_fallback
18
+ # Rows per call into Rust, and bytes read from the stream at a time.
19
+ ROWS = 512
20
+ CHUNK = 64 * 1024
21
+ # The RowBuilder kinds of OdsTable's cells, and the reasons of a missing value.
22
+ KINDS = %i[text number iso_date duration boolean error missing].freeze
23
+ MISSING = %i[invalid_decimal invalid_date invalid_boolean].freeze
24
+ # The inflated total of ZipContainer.
25
+ TOTAL = :@inflated_total
26
+
27
+ @fallbacks = 0
28
+
29
+ class << self
30
+ # @return [Integer] the table reads handed to calcsheet's parser in this process, for specs and diagnostics
31
+ attr_accessor :fallbacks
32
+ end
33
+
34
+ private
35
+
36
+ def each_row(index, cast, types, start_row, &block)
37
+ charged = @zip.instance_variable_get(TOTAL)
38
+ learned = @scanner.instance_variables.to_h { [ _1, @scanner.instance_variable_get(_1) ] }
39
+ last = 0
40
+ finished = catch(FALLBACK) do
41
+ read_natively(index, cast, types, start_row) do |row|
42
+ last = row.number
43
+ yield row
44
+ end
45
+ true
46
+ end
47
+ return if finished
48
+
49
+ self.class.fallbacks += 1
50
+ @zip.instance_variable_set(TOTAL, charged)
51
+ (@scanner.instance_variables - learned.keys).each { @scanner.remove_instance_variable(_1) }
52
+ learned.each { |name, value| @scanner.instance_variable_set(name, value) }
53
+ super(index, cast, types, start_row) { |row| yield row if row.number > last }
54
+ end
55
+
56
+ # Yields the rows read by OdsTable, as ContentParser yields them; throws FALLBACK when calcsheet's parser must
57
+ # read the table. The errors of the caller's block are its own.
58
+ def read_natively(index, cast, types, start_row)
59
+ guarded { sheet_at(index) }
60
+ io = guarded { @scanner.io_at_sheet(index, row: start_row) } or return
61
+ table = guarded do
62
+ prefixes = @scanner.prefixes
63
+ @formats = @scanner.cell_formats
64
+ OdsTable.new(prefixes.values_at(:table, :office, :text, :calcext), @formats.keys, @max_cell_bytes,
65
+ io.first_row, start_row)
66
+ end
67
+ @format_list = @formats.values
68
+ @column_formats = []
69
+ builder = RowBuilder.new(cast, types, number_errors: :raw)
70
+ # Read into one String: a new one per chunk would leave the whole table as garbage before the GC runs.
71
+ buffer = String.new(capacity: CHUNK)
72
+ loop do
73
+ batch = guarded { table.rows(ROWS) }
74
+ throw FALLBACK if batch == false
75
+ if batch.nil?
76
+ chunk = guarded { io.read(CHUNK, buffer) }
77
+ chunk ? table.feed(chunk) : table.finish
78
+ next
79
+ end
80
+
81
+ firsts, lasts, ends, cells, @column_formats, done = batch
82
+ from = 0
83
+ firsts.each_with_index do |number, position|
84
+ to = ends[position]
85
+ guarded { add_cells(builder, cells, from, to) }
86
+ from = to
87
+ last = lasts[position]
88
+ row = builder.finish(last)
89
+ next if row.nil? || last < start_row
90
+
91
+ [ number, start_row ].max.upto(last - 1) { |each| yield RawRow.new(number: each, values: row.values.dup, errors: row.errors&.dup) }
92
+ yield row
93
+ end
94
+ break if done
95
+ end
96
+ ensure
97
+ io&.close
98
+ end
99
+
100
+ # Runs the block, throwing FALLBACK for any error it raises: calcsheet's parser raises it again, where it
101
+ # would.
102
+ def guarded
103
+ yield
104
+ rescue StandardError
105
+ throw FALLBACK
106
+ end
107
+
108
+ # Adds the cells +from...to+ of a batch to the builder, as ContentParser#finish_cell does.
109
+ def add_cells(builder, cells, from, to)
110
+ while from < to
111
+ column = cells[from]
112
+ repeat = cells[from + 1]
113
+ kind = KINDS[cells[from + 2]]
114
+ missing = MISSING[cells[from + 3]] if kind == :missing
115
+ raw = cells[from + 4]
116
+ style = cells[from + 5]
117
+ if repeat == 1
118
+ builder.add(column, kind, raw, format_of(kind, style, column), missing: missing)
119
+ elsif kind == :number || kind == :iso_date
120
+ builder.add_run(column, column + repeat - 1, kind, raw, missing: missing) { format_of(kind, style, _1) }
121
+ else
122
+ builder.add_run(column, column + repeat - 1, kind, raw, missing: missing)
123
+ end
124
+ from += 6
125
+ end
126
+ end
127
+
128
+ # ContentParser#format_of: a number takes only the padding of a zero-padding style, a date its style's format.
129
+ def format_of(kind, style, column)
130
+ return unless kind == :number || kind == :iso_date
131
+
132
+ format = format_at(style, column)
133
+ kind == :iso_date || (format && format[0] == :general) ? format : nil
134
+ end
135
+
136
+ # ContentParser#format_at: the format of the cell's or row's style (-1: a style without one), else of the
137
+ # default cell style of its column (-2).
138
+ def format_at(style, column)
139
+ return if @formats.empty?
140
+ return @format_list[style] if style >= 0
141
+ return if style == -1
142
+
143
+ position = (0...@column_formats.size / 3).bsearch { |entry| @column_formats[entry * 3 + 1] >= column }
144
+ @format_list[@column_formats[position * 3 + 2]] if position && @column_formats[position * 3] <= column
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ module Native
5
+ # The version of the gem; the Rust core (Calcsheet::Native.core_version) has the same.
6
+ VERSION = "0.1.0"
7
+ end
8
+ end