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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/Cargo.lock +283 -0
- data/Cargo.toml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +65 -0
- data/ext/calcsheet_native/Cargo.toml +14 -0
- data/ext/calcsheet_native/extconf.rb +6 -0
- data/ext/calcsheet_native/src/csv.rs +309 -0
- data/ext/calcsheet_native/src/gvl.rs +47 -0
- data/ext/calcsheet_native/src/lib.rs +470 -0
- data/ext/calcsheet_native/src/ods.rs +815 -0
- data/ext/calcsheet_native/src/xlsx.rs +814 -0
- data/ext/calcsheet_native/src/xml.rs +370 -0
- data/lib/calcsheet/native/csv_reader.rb +122 -0
- data/lib/calcsheet/native/ods_reader.rb +148 -0
- data/lib/calcsheet/native/version.rb +8 -0
- data/lib/calcsheet/native/xlsx_reader.rb +208 -0
- data/lib/calcsheet/native.rb +31 -0
- metadata +100 -0
|
@@ -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
|