pivotkit-xlsx 0.0.0-stage → 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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/THIRD_PARTY_LICENSES.txt +85 -0
- package/dist/chunks/chunk-FI6VROG7.js +1 -0
- package/dist/chunks/chunk-GPNA7WRL.js +1 -0
- package/dist/chunks/import-csv-QZDVETIW.js +1 -0
- package/dist/chunks/import-json-GKBVWJSK.js +1 -0
- package/dist/chunks/reader-VGOKIFNW.js +9 -0
- package/dist/index.js +1 -0
- package/package.json +63 -4
- package/src/index.js +21 -0
- package/src/reader.js +221 -0
- package/src/workbook.js +79 -0
- package/src/xml.js +278 -0
- package/src/zip.js +292 -0
- package/types/index.d.ts +3 -0
- package/types/reader.d.ts +15 -0
- package/types/workbook.d.ts +21 -0
- package/types/xml.d.ts +40 -0
- package/types/zip.d.ts +55 -0
package/src/reader.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The reader of pivotkit-xlsx (loaded on demand by the plugin in index.js): an Excel workbook (.xlsx)
|
|
3
|
+
* into a table of pivotkit-core/import.
|
|
4
|
+
*
|
|
5
|
+
* 1. Checks of its own before any XML is parsed: the file size (limits.fileBytes); what the file is
|
|
6
|
+
* (an .xls, an encrypted workbook, a binary .xlsb or a macro workbook is refused with what to do);
|
|
7
|
+
* the ZIP directory: parts (limits.archiveEntries) and unpacked size (limits.unpackedBytes), so a
|
|
8
|
+
* ZIP bomb is refused before it is unpacked; no vbaProject.bin; no <!DOCTYPE or <!ENTITY in the
|
|
9
|
+
* XML parts it reads.
|
|
10
|
+
* 2. It unpacks only the parts the chosen sheet needs, each into a buffer of the size the directory
|
|
11
|
+
* states, and leaves out the sheet's rows without a value (the used range of formatted empty
|
|
12
|
+
* cells, xml.js).
|
|
13
|
+
* 3. read-excel-file (MIT) parses those parts: shared strings, styles (a date is a number with a date
|
|
14
|
+
* format), the values Excel saved (never a formula). It gets them as a ZIP without compression,
|
|
15
|
+
* so it unpacks nothing itself and starts no worker: no `blob:` worker, which a strict
|
|
16
|
+
* Content-Security-Policy would block.
|
|
17
|
+
* 4. pivotkit-core's `createTable` finds the header, cleans the values and infers the types, as for
|
|
18
|
+
* CSV and JSON.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { bytesOf, cleanText, createTable, fileError, fileName, readOptions } from "pivotkit-core/import";
|
|
22
|
+
import { readSheet } from "read-excel-file/universal";
|
|
23
|
+
import { checkXml, createSheetFilter, createXmlCheck } from "./xml.js";
|
|
24
|
+
import { createStoredZip, readDirectory, unpack, unpackStream } from "./zip.js";
|
|
25
|
+
import { sharedParts, worksheets } from "./workbook.js";
|
|
26
|
+
|
|
27
|
+
const CFB = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
28
|
+
const ZIP = [0x50, 0x4b, 0x03, 0x04];
|
|
29
|
+
const startsWith = (bytes, sig) => sig.every((b, i) => bytes[i] === b);
|
|
30
|
+
/** A sheet name in a message: it comes from the file. */
|
|
31
|
+
const shown = (name) => cleanText(String(name)).slice(0, 100);
|
|
32
|
+
/** The most a workbook's list of sheets and its relationships may be; real ones are a few kilobytes. */
|
|
33
|
+
const MAX_WORKBOOK_PART = 4 * 1024 * 1024;
|
|
34
|
+
const ascii = (s) => Array.from(s, (c) => c.charCodeAt(0));
|
|
35
|
+
// a password-protected .xlsx is a compound file holding the encrypted package (UTF-16 stream name)
|
|
36
|
+
const ENCRYPTED_STREAM = ascii("EncryptedPackage").flatMap((c) => [c, 0]);
|
|
37
|
+
|
|
38
|
+
function containsBytes(bytes, pattern) {
|
|
39
|
+
const first = pattern[0];
|
|
40
|
+
for (let i = bytes.indexOf(first); i !== -1 && i <= bytes.length - pattern.length; i = bytes.indexOf(first, i + 1)) {
|
|
41
|
+
let k = 1;
|
|
42
|
+
while (k < pattern.length && bytes[i + k] === pattern[k]) k++;
|
|
43
|
+
if (k === pattern.length) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Refuses what is no .xlsx, with what it is and what to do. */
|
|
49
|
+
function checkKind(bytes, file) {
|
|
50
|
+
if (/\.xlsm$/i.test(file)) {
|
|
51
|
+
throw fileError("MACROS", file, "a workbook with macros (.xlsm) is not loaded; save it as an Excel workbook (.xlsx) or as CSV.");
|
|
52
|
+
}
|
|
53
|
+
if (startsWith(bytes, CFB)) {
|
|
54
|
+
if (containsBytes(bytes, ENCRYPTED_STREAM)) {
|
|
55
|
+
throw fileError("ENCRYPTED", file, "the workbook is protected with a password; save it without one, then load it.");
|
|
56
|
+
}
|
|
57
|
+
throw fileError("XLS", file, "this is an Excel 97-2003 workbook (.xls); save it as an Excel workbook (.xlsx) or as CSV, then load it.");
|
|
58
|
+
}
|
|
59
|
+
if (!startsWith(bytes, ZIP)) {
|
|
60
|
+
throw fileError("WRONG_TYPE", file, "this is no Excel workbook (.xlsx); check the file, or load a CSV file with the CSV reader.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reads and checks a workbook's directory and its small parts.
|
|
66
|
+
* @returns {Promise<{ entries: Map<string, import("./zip.js").ZipEntry>, parts: Map<string, Uint8Array>, sheets: ReturnType<typeof worksheets>, shared: ReturnType<typeof sharedParts> }>}
|
|
67
|
+
*/
|
|
68
|
+
async function open(bytes, file, limits, between) {
|
|
69
|
+
checkKind(bytes, file);
|
|
70
|
+
const list = readDirectory(bytes, file, limits);
|
|
71
|
+
const entries = new Map(list.map((e) => [e.name, e]));
|
|
72
|
+
for (const e of list) {
|
|
73
|
+
if (/(^|\/)vbaProject\.bin$/i.test(e.name)) {
|
|
74
|
+
throw fileError("MACROS", file, "the workbook holds macros (vbaProject.bin); save it as an Excel workbook (.xlsx) without macros, or as CSV.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!entries.has("xl/workbook.xml")) {
|
|
78
|
+
if (entries.has("xl/workbook.bin")) {
|
|
79
|
+
throw fileError("XLSB", file, "this is an Excel binary workbook (.xlsb); save it as an Excel workbook (.xlsx) or as CSV, then load it.");
|
|
80
|
+
}
|
|
81
|
+
throw fileError("WRONG_TYPE", file, "this ZIP file is no Excel workbook (it has no xl/workbook.xml).");
|
|
82
|
+
}
|
|
83
|
+
/** @type {Map<string, Uint8Array>} */
|
|
84
|
+
const parts = new Map();
|
|
85
|
+
const read = async (path, max = Infinity) => {
|
|
86
|
+
const entry = entries.get(path);
|
|
87
|
+
if (!entry) return null;
|
|
88
|
+
if (entry.size > max) {
|
|
89
|
+
throw fileError("DAMAGED", file, `the part "${path}" is ${entry.size.toLocaleString("en-US")} bytes, far more than a workbook's list of sheets holds; it is refused.`);
|
|
90
|
+
}
|
|
91
|
+
const data = await unpack(bytes, entry, file, between);
|
|
92
|
+
checkXml(data, file, path);
|
|
93
|
+
parts.set(path, data);
|
|
94
|
+
return data;
|
|
95
|
+
};
|
|
96
|
+
const types = await read("[Content_Types].xml", MAX_WORKBOOK_PART);
|
|
97
|
+
if (types && /macroEnabled/i.test(new TextDecoder().decode(types))) {
|
|
98
|
+
throw fileError("MACROS", file, "a workbook with macros (.xlsm) is not loaded; save it as an Excel workbook (.xlsx) or as CSV.");
|
|
99
|
+
}
|
|
100
|
+
const decode = (data) => (data ? new TextDecoder().decode(data) : "");
|
|
101
|
+
const workbook = decode(await read("xl/workbook.xml", MAX_WORKBOOK_PART));
|
|
102
|
+
const rels = decode(await read("xl/_rels/workbook.xml.rels", MAX_WORKBOOK_PART));
|
|
103
|
+
const sheets = worksheets(workbook, rels);
|
|
104
|
+
if (sheets.length === 0) {
|
|
105
|
+
throw fileError("NO_TABLE", file, "the workbook has no worksheet.");
|
|
106
|
+
}
|
|
107
|
+
return { entries, parts, sheets, shared: sharedParts(rels) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The sheet asked for: by name, by index (0-based), else the first visible one. */
|
|
111
|
+
function pickSheet(sheets, wanted, file) {
|
|
112
|
+
if (typeof wanted === "string") {
|
|
113
|
+
const s = sheets.find((x) => x.name === wanted);
|
|
114
|
+
if (!s) throw fileError("NO_SHEET", file, `the workbook has no sheet "${shown(wanted)}".`, { sheet: wanted });
|
|
115
|
+
return s;
|
|
116
|
+
}
|
|
117
|
+
if (typeof wanted === "number") {
|
|
118
|
+
const s = sheets[wanted];
|
|
119
|
+
if (!s) throw fileError("NO_SHEET", file, `the workbook has no sheet ${wanted + 1}.`, { sheet: wanted });
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
return sheets.find((s) => s.visible) ?? sheets[0];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The worksheets of a workbook: name, index (0-based) and whether it is visible.
|
|
127
|
+
* @param {Blob | ArrayBuffer | Uint8Array} file
|
|
128
|
+
* @param {import("pivotkit-core/import").ReadOptions} [options]
|
|
129
|
+
* @returns {Promise<import("pivotkit-core/import").SheetInfo[]>}
|
|
130
|
+
*/
|
|
131
|
+
export async function sheets(file, options) {
|
|
132
|
+
const o = readOptions(options);
|
|
133
|
+
const name = fileName(file, o);
|
|
134
|
+
const bytes = await bytesOf(file, o.limits, name);
|
|
135
|
+
const { sheets: list } = await open(bytes, name, o.limits, () => o.signal?.throwIfAborted());
|
|
136
|
+
return list.map(({ name: sheet, index, visible }) => ({ name: sheet, index, visible }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Reads one sheet of a workbook into a table: `options.sheet` (a name, or an index from 0), else the
|
|
141
|
+
* first visible one.
|
|
142
|
+
* @param {Blob | ArrayBuffer | Uint8Array} file
|
|
143
|
+
* @param {import("pivotkit-core/import").ReadOptions} [options]
|
|
144
|
+
* @returns {Promise<import("pivotkit-core/import").ImportTable>}
|
|
145
|
+
*/
|
|
146
|
+
export async function read(file, options) {
|
|
147
|
+
const o = readOptions(options);
|
|
148
|
+
const name = fileName(file, o);
|
|
149
|
+
const signal = o.signal;
|
|
150
|
+
signal?.throwIfAborted();
|
|
151
|
+
const bytes = await bytesOf(file, o.limits, name);
|
|
152
|
+
o.onProgress?.({ phase: "read", done: bytes.length, total: bytes.length });
|
|
153
|
+
const between = () => signal?.throwIfAborted();
|
|
154
|
+
const { entries, parts, sheets: list, shared } = await open(bytes, name, o.limits, between);
|
|
155
|
+
const sheet = pickSheet(list, o.sheet, name);
|
|
156
|
+
|
|
157
|
+
for (const path of [shared.sharedStrings, shared.styles]) {
|
|
158
|
+
const entry = entries.get(path);
|
|
159
|
+
if (entry && !parts.has(path)) {
|
|
160
|
+
const data = await unpack(bytes, entry, name, between);
|
|
161
|
+
checkXml(data, name, path);
|
|
162
|
+
parts.set(path, data);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const entry = entries.get(sheet.path);
|
|
166
|
+
if (!entry) throw fileError("DAMAGED", name, `the part of sheet "${shown(sheet.name)}" is missing; open the workbook in Excel and save it again.`);
|
|
167
|
+
// read-excel-file reads the parts from a ZIP: one without compression, of only these parts, with
|
|
168
|
+
// the sheet's rows without a value left out while it streams in
|
|
169
|
+
// sized for the whole sheet: the buffer never grows (no copy); what the filter leaves out is never
|
|
170
|
+
// written, and transfer() below gives the unused end back
|
|
171
|
+
let room = entry.size + 22;
|
|
172
|
+
for (const [path, data] of parts) room += data.length + 2 * (76 + path.length);
|
|
173
|
+
const zip = createStoredZip(room + 2 * (76 + sheet.path.length));
|
|
174
|
+
for (const [path, data] of parts) zip.add(path, data);
|
|
175
|
+
parts.clear();
|
|
176
|
+
const sheetPart = zip.begin(sheet.path);
|
|
177
|
+
// a title block above the header may hold a few rows more than the table
|
|
178
|
+
const filter = createSheetFilter(sheetPart, { file: name, maxRow: o.limits.rows + 1_000, maxColumn: o.limits.columns, maxCells: o.limits.importCells + o.limits.columns * 1_000 });
|
|
179
|
+
const check = createXmlCheck(name, sheet.path);
|
|
180
|
+
let unpacked = 0;
|
|
181
|
+
await unpackStream(bytes, entry, name, (chunk) => {
|
|
182
|
+
check(chunk);
|
|
183
|
+
filter.push(chunk);
|
|
184
|
+
unpacked += chunk.length;
|
|
185
|
+
}, () => {
|
|
186
|
+
signal?.throwIfAborted();
|
|
187
|
+
o.onProgress?.({ phase: "unpack", done: unpacked, total: entry.size });
|
|
188
|
+
});
|
|
189
|
+
const trimmed = filter.end();
|
|
190
|
+
sheetPart.end();
|
|
191
|
+
let stored = zip.finish();
|
|
192
|
+
signal?.throwIfAborted();
|
|
193
|
+
o.onProgress?.({ phase: "parse", done: 0, total: trimmed.lastValueRow });
|
|
194
|
+
/** @type {unknown[][]} */
|
|
195
|
+
let rows;
|
|
196
|
+
try {
|
|
197
|
+
// an ArrayBuffer of the ZIP's length: transfer() shrinks the buffer, where it can, without a copy
|
|
198
|
+
const whole = /** @type {ArrayBuffer & { transfer?: (length: number) => ArrayBuffer }} */ (stored.buffer);
|
|
199
|
+
const buffer = whole.byteLength === stored.length ? whole : typeof whole.transfer === "function" ? whole.transfer(stored.length) : stored.slice().buffer;
|
|
200
|
+
stored = null;
|
|
201
|
+
rows = await readSheet(buffer, sheet.name);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw fileError("DAMAGED", name, `sheet "${shown(sheet.name)}" cannot be read (${String(/** @type {Error} */ (error)?.message ?? error).slice(0, 200)}); open the workbook in Excel and save it again.`);
|
|
204
|
+
}
|
|
205
|
+
signal?.throwIfAborted();
|
|
206
|
+
o.onProgress?.({ phase: "parse", done: trimmed.lastValueRow, total: trimmed.lastValueRow });
|
|
207
|
+
|
|
208
|
+
const table = createTable({ file: name, limits: o.limits, locale: o.locale, hints: { decimal: ".", serialDates: true } });
|
|
209
|
+
table.note("SHEET", { sheet: sheet.name, count: list.length, visible: list.filter((s) => s.visible).length });
|
|
210
|
+
if (trimmed.lastRow > trimmed.lastValueRow && trimmed.lastRow - trimmed.lastValueRow >= 100) {
|
|
211
|
+
table.note("USED_RANGE", { usedRow: trimmed.lastRow, lastRow: trimmed.lastValueRow });
|
|
212
|
+
}
|
|
213
|
+
for (let r = 0; r < rows.length; r++) {
|
|
214
|
+
table.row(rows[r]);
|
|
215
|
+
rows[r] = null;
|
|
216
|
+
}
|
|
217
|
+
// read-excel-file reads a cell with an error (#N/A, #DIV/0!) as empty; the filter counted them
|
|
218
|
+
if (trimmed.errors > 0) table.note("ERROR_CELLS", { count: trimmed.errors });
|
|
219
|
+
signal?.throwIfAborted();
|
|
220
|
+
return table.finish(o.types);
|
|
221
|
+
}
|
package/src/workbook.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The sheets of a workbook (xl/workbook.xml) and the parts they are in (xl/_rels/workbook.xml.rels):
|
|
3
|
+
* names, order, hidden or not, and the path of each worksheet. Both parts are small and plain; their
|
|
4
|
+
* elements are read with patterns over the text after the XML check (xml.js). Not part of the public
|
|
5
|
+
* API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
9
|
+
|
|
10
|
+
/** XML text with its five named entities and character references decoded. */
|
|
11
|
+
export function decodeXml(text) {
|
|
12
|
+
return text.replace(/&(?:#x([0-9a-f]{1,6})|#(\d{1,7})|(amp|lt|gt|quot|apos));/gi, (whole, hex, dec, name) => {
|
|
13
|
+
if (name) return ENTITIES[name.toLowerCase()] ?? whole;
|
|
14
|
+
const code = hex ? parseInt(hex, 16) : Number(dec);
|
|
15
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "";
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The attributes of one start tag's text (`name="..."`, prefixes dropped: `r:id` is `id`). */
|
|
20
|
+
function attributes(tag) {
|
|
21
|
+
/** @type {Map<string, string>} */
|
|
22
|
+
const out = new Map();
|
|
23
|
+
for (const m of tag.matchAll(/(?:[\w.-]+:)?([\w.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
|
|
24
|
+
if (!out.has(m[1])) out.set(m[1], decodeXml(m[3] ?? m[4] ?? ""));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The start tags of elements named `local` (any namespace prefix). */
|
|
30
|
+
function tags(xml, local) {
|
|
31
|
+
// [^<>]: a tag never holds "<", so each attempt ends at the next one: linear, also for "<sheet" repeated without ">"
|
|
32
|
+
return [...xml.matchAll(new RegExp(`<(?:[\\w.-]+:)?${local}\\b([^<>]*)>`, "g"))].map((m) => attributes(m[1]));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "worksheets/sheet1.xml" relative to xl/ (or "/xl/..." absolute) -> "xl/worksheets/sheet1.xml" */
|
|
36
|
+
export function partPath(target) {
|
|
37
|
+
const parts = (target.startsWith("/") ? target.slice(1) : `xl/${target}`).split("/");
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const p of parts) {
|
|
40
|
+
if (p === "..") out.pop();
|
|
41
|
+
else if (p !== "." && p !== "") out.push(p);
|
|
42
|
+
}
|
|
43
|
+
return out.join("/");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The worksheets of a workbook in their order (chart sheets and dialog sheets are not tables).
|
|
48
|
+
* @param {string} workbookXml
|
|
49
|
+
* @param {string} relsXml
|
|
50
|
+
* @returns {Array<{ name: string, index: number, visible: boolean, path: string }>}
|
|
51
|
+
*/
|
|
52
|
+
export function worksheets(workbookXml, relsXml) {
|
|
53
|
+
const targets = new Map();
|
|
54
|
+
for (const rel of tags(relsXml, "Relationship")) {
|
|
55
|
+
const type = rel.get("Type") ?? "";
|
|
56
|
+
if (/\/worksheet$/.test(type) && rel.has("Id") && rel.has("Target") && rel.get("TargetMode") !== "External") {
|
|
57
|
+
targets.set(rel.get("Id"), partPath(rel.get("Target")));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const sheet of tags(workbookXml, "sheet")) {
|
|
62
|
+
const path = targets.get(sheet.get("id") ?? "");
|
|
63
|
+
if (path === undefined) continue;
|
|
64
|
+
const state = sheet.get("state");
|
|
65
|
+
out.push({ name: sheet.get("name") ?? "", index: out.length, visible: state !== "hidden" && state !== "veryHidden", path });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The paths of the shared strings and the styles, from the workbook's relationships. */
|
|
71
|
+
export function sharedParts(relsXml) {
|
|
72
|
+
const out = { sharedStrings: "xl/sharedStrings.xml", styles: "xl/styles.xml" };
|
|
73
|
+
for (const rel of tags(relsXml, "Relationship")) {
|
|
74
|
+
const type = rel.get("Type") ?? "";
|
|
75
|
+
if (/\/sharedStrings$/.test(type) && rel.has("Target")) out.sharedStrings = partPath(rel.get("Target"));
|
|
76
|
+
if (/\/styles$/.test(type) && rel.has("Target")) out.styles = partPath(rel.get("Target"));
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
package/src/xml.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Checks and trims of the XML parts of a workbook, on their bytes, before read-excel-file sees them:
|
|
3
|
+
*
|
|
4
|
+
* - `checkXml`: no document type declaration and no entity declaration (`<!DOCTYPE`, `<!ENTITY`).
|
|
5
|
+
* An .xlsx never needs either; refusing them rules out entity expansion and external entities
|
|
6
|
+
* whatever the XML parser does with them.
|
|
7
|
+
* - `createSheetFilter`: the rows of a sheet that hold no value are left out. Excel's used range often
|
|
8
|
+
* reaches far below the data (formatted empty cells to row 900,000); those rows would otherwise be
|
|
9
|
+
* unpacked into memory, parsed and turned into empty rows. A row is kept when it has a cell with
|
|
10
|
+
* content (`</c>`: a value, an inline string or a formula); a row of empty cells (`<c r="A9" s="1"/>`)
|
|
11
|
+
* or a self-closing `<row/>` goes. Rows are numbered by their cells' `r` attributes, so leaving rows
|
|
12
|
+
* out moves no value.
|
|
13
|
+
*
|
|
14
|
+
* Not part of the public API.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileError, limitError } from "pivotkit-core/import";
|
|
18
|
+
|
|
19
|
+
const ascii = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0));
|
|
20
|
+
const LT = 0x3c;
|
|
21
|
+
const BANG = 0x21;
|
|
22
|
+
const DOCTYPE = ascii("DOCTYPE");
|
|
23
|
+
const ENTITY = ascii("ENTITY");
|
|
24
|
+
const SHEET_DATA = ascii("<sheetData");
|
|
25
|
+
const SHEET_DATA_END = ascii("</sheetData>");
|
|
26
|
+
const ROW = ascii("<row");
|
|
27
|
+
const ROW_END = ascii("</row>");
|
|
28
|
+
const CELL_END = ascii("</c>");
|
|
29
|
+
const ERROR_TYPE = ascii(' t="e"');
|
|
30
|
+
|
|
31
|
+
/** Index of `pattern` in `buf` from `from`, or -1. */
|
|
32
|
+
export function indexOf(buf, pattern, from = 0, to = buf.length) {
|
|
33
|
+
const first = pattern[0];
|
|
34
|
+
const last = to - pattern.length;
|
|
35
|
+
for (let i = buf.indexOf(first, from); i !== -1 && i <= last; i = buf.indexOf(first, i + 1)) {
|
|
36
|
+
let k = 1;
|
|
37
|
+
while (k < pattern.length && buf[i + k] === pattern[k]) k++;
|
|
38
|
+
if (k === pattern.length) return i;
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether `buf` at `i` holds `word`, letters compared without case. */
|
|
44
|
+
function wordAt(buf, i, word) {
|
|
45
|
+
for (let k = 0; k < word.length; k++) {
|
|
46
|
+
if ((buf[i + k] | 0x20) !== (word[k] | 0x20)) return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A check of an XML part's bytes, fed piece by piece: throws at a `<!DOCTYPE` or `<!ENTITY`.
|
|
53
|
+
* @param {string} file
|
|
54
|
+
* @param {string} part
|
|
55
|
+
*/
|
|
56
|
+
export function createXmlCheck(file, part) {
|
|
57
|
+
let tail = new Uint8Array(0);
|
|
58
|
+
let first = true;
|
|
59
|
+
return (chunk) => {
|
|
60
|
+
// the check reads ASCII bytes: an XML part in UTF-16 or UTF-32 (a byte order mark, zero bytes)
|
|
61
|
+
// could hide a declaration from it, and Excel writes UTF-8
|
|
62
|
+
if ((first && chunk.length >= 2 && ((chunk[0] === 0xfe && chunk[1] === 0xff) || (chunk[0] === 0xff && chunk[1] === 0xfe))) || chunk.indexOf(0) !== -1) {
|
|
63
|
+
throw fileError("XML_DECLARATION", file, `the part "${part}" is not UTF-8 XML, which a workbook always is; it is refused.`, { part });
|
|
64
|
+
}
|
|
65
|
+
first = false;
|
|
66
|
+
const buf = tail.length ? concat(tail, chunk) : chunk;
|
|
67
|
+
// "!" is rare in a workbook's XML (comments, CDATA, text); "<" is in every tag
|
|
68
|
+
for (let i = buf.indexOf(BANG, 1); i !== -1; i = buf.indexOf(BANG, i + 1)) {
|
|
69
|
+
if (buf[i - 1] === LT && (wordAt(buf, i + 1, DOCTYPE) || wordAt(buf, i + 1, ENTITY))) {
|
|
70
|
+
throw fileError("XML_DECLARATION", file, `the part "${part}" declares a document type or entities, which a workbook never needs; it is refused.`, { part });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
tail = buf.slice(Math.max(0, buf.length - 9));
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Checks a whole XML part. */
|
|
78
|
+
export function checkXml(bytes, file, part) {
|
|
79
|
+
createXmlCheck(file, part)(bytes);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function concat(a, b) {
|
|
83
|
+
const out = new Uint8Array(a.length + b.length);
|
|
84
|
+
out.set(a);
|
|
85
|
+
out.set(b, a.length);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The row number of a `<row ...>` tag (its `r` attribute), or 0. */
|
|
90
|
+
function rowNumber(buf, from, to) {
|
|
91
|
+
for (let i = from; i < to - 3; i++) {
|
|
92
|
+
// ' r="'
|
|
93
|
+
if ((buf[i] === 0x20 || buf[i] === 0x09 || buf[i] === 0x0a || buf[i] === 0x0d) && buf[i + 1] === 0x72 && buf[i + 2] === 0x3d && buf[i + 3] === 0x22) {
|
|
94
|
+
let n = 0;
|
|
95
|
+
for (let k = i + 4; k < to && buf[k] >= 0x30 && buf[k] <= 0x39; k++) n = n * 10 + (buf[k] - 0x30);
|
|
96
|
+
return n;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The largest row (or start of a sheet before its <sheetData>) the filter holds while it waits for its end. */
|
|
103
|
+
const MAX_PENDING = 64 * 1024 * 1024;
|
|
104
|
+
const CELL = ascii("<c ");
|
|
105
|
+
|
|
106
|
+
/** The highest column (1 for A) and row of the cells' `r` attributes in buf[from, to). */
|
|
107
|
+
function cellExtent(buf, from, to) {
|
|
108
|
+
let column = 0;
|
|
109
|
+
let row = 0;
|
|
110
|
+
for (let i = indexOf(buf, CELL, from, to); i !== -1; i = indexOf(buf, CELL, i + 3, to)) {
|
|
111
|
+
const gt = buf.indexOf(0x3e, i);
|
|
112
|
+
const stop = gt === -1 || gt > to ? to : gt;
|
|
113
|
+
for (let k = i + 2; k < stop - 3; k++) {
|
|
114
|
+
if ((buf[k] === 0x20 || buf[k] === 0x09) && buf[k + 1] === 0x72 && buf[k + 2] === 0x3d && buf[k + 3] === 0x22) {
|
|
115
|
+
let c = 0;
|
|
116
|
+
let q = k + 4;
|
|
117
|
+
for (; q < stop && ((buf[q] | 0x20) >= 0x61 && (buf[q] | 0x20) <= 0x7a) && c <= 1e7; q++) c = c * 26 + ((buf[q] | 0x20) - 0x60);
|
|
118
|
+
let r = 0;
|
|
119
|
+
for (; q < stop && buf[q] >= 0x30 && buf[q] <= 0x39 && r <= 1e12; q++) r = r * 10 + (buf[q] - 0x30);
|
|
120
|
+
column = Math.max(column, c);
|
|
121
|
+
row = Math.max(row, r);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { column, row };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Streams a sheet's XML and leaves out its rows without a value. Feed pieces with `push`; the XML
|
|
131
|
+
* kept goes to `out.write`; `end()` gives the row numbers seen.
|
|
132
|
+
*
|
|
133
|
+
* It also checks where the values are against the limits, before read-excel-file sees them: the
|
|
134
|
+
* parser makes a dense array of the sheet's extent (every row up to the last row with a value, each
|
|
135
|
+
* as wide as the widest), so a value in row 1,000,000 or column XFD costs that much memory however
|
|
136
|
+
* small the file is. A value beyond `maxRow` or `maxColumn`, or an extent of more than `maxCells`,
|
|
137
|
+
* stops the read with LIMIT_EXCEEDED.
|
|
138
|
+
* @param {{ write: (bytes: Uint8Array, from?: number, to?: number) => void }} out
|
|
139
|
+
* @param {{ file?: string, maxRow?: number, maxColumn?: number, maxCells?: number }} [limits]
|
|
140
|
+
*/
|
|
141
|
+
export function createSheetFilter(out, limits = {}) {
|
|
142
|
+
const file = limits.file ?? "file";
|
|
143
|
+
let widest = 0;
|
|
144
|
+
let state = 0; // 0 before <sheetData>, 1 inside, 2 after
|
|
145
|
+
let pending = new Uint8Array(0);
|
|
146
|
+
let lastValueRow = 0;
|
|
147
|
+
let lastRow = 0;
|
|
148
|
+
let dropped = 0;
|
|
149
|
+
let errors = 0;
|
|
150
|
+
|
|
151
|
+
const run = (final) => {
|
|
152
|
+
const buf = pending;
|
|
153
|
+
let i = 0;
|
|
154
|
+
while (i < buf.length) {
|
|
155
|
+
if (state === 0) {
|
|
156
|
+
const at = indexOf(buf, SHEET_DATA, i);
|
|
157
|
+
if (at === -1) {
|
|
158
|
+
const keep = final ? buf.length : Math.max(i, buf.length - SHEET_DATA.length);
|
|
159
|
+
out.write(buf, i, keep);
|
|
160
|
+
i = keep;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
const close = buf.indexOf(0x3e, at);
|
|
164
|
+
if (close === -1) {
|
|
165
|
+
out.write(buf, i, at);
|
|
166
|
+
i = at;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
out.write(buf, i, close + 1);
|
|
170
|
+
i = close + 1;
|
|
171
|
+
state = buf[close - 1] === 0x2f ? 2 : 1; // <sheetData/>
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (state === 2) {
|
|
175
|
+
out.write(buf, i, buf.length);
|
|
176
|
+
i = buf.length;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
// inside <sheetData>: whole rows at a time
|
|
180
|
+
const lt = buf.indexOf(LT, i);
|
|
181
|
+
if (lt === -1) {
|
|
182
|
+
i = buf.length; // whitespace between rows
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
if (indexOf(buf, SHEET_DATA_END, lt, lt + SHEET_DATA_END.length) === lt) {
|
|
186
|
+
state = 2;
|
|
187
|
+
i = lt;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (indexOf(buf, ROW, lt, lt + ROW.length) !== lt) {
|
|
191
|
+
// anything else in sheetData (a comment, say) is kept as it is
|
|
192
|
+
const gt = buf.indexOf(0x3e, lt);
|
|
193
|
+
if (gt === -1) {
|
|
194
|
+
i = lt;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
out.write(buf, lt, gt + 1);
|
|
198
|
+
i = gt + 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const tagEnd = buf.indexOf(0x3e, lt);
|
|
202
|
+
if (tagEnd === -1) {
|
|
203
|
+
i = lt;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
const number = rowNumber(buf, lt, tagEnd);
|
|
207
|
+
if (buf[tagEnd - 1] === 0x2f) {
|
|
208
|
+
// <row .../>: no cells
|
|
209
|
+
lastRow = Math.max(lastRow, number);
|
|
210
|
+
dropped++;
|
|
211
|
+
i = tagEnd + 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const end = indexOf(buf, ROW_END, tagEnd);
|
|
215
|
+
if (end === -1) {
|
|
216
|
+
i = lt;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const stop = end + ROW_END.length;
|
|
220
|
+
lastRow = Math.max(lastRow, number);
|
|
221
|
+
if (indexOf(buf, CELL_END, tagEnd, end) !== -1) {
|
|
222
|
+
const extent = cellExtent(buf, tagEnd, end);
|
|
223
|
+
const at = Math.max(number, extent.row);
|
|
224
|
+
widest = Math.max(widest, extent.column);
|
|
225
|
+
if (limits.maxRow !== undefined && at > limits.maxRow) throw limitError("rows", at, limits.maxRow, file, "rows");
|
|
226
|
+
if (limits.maxColumn !== undefined && widest > limits.maxColumn) throw limitError("columns", widest, limits.maxColumn, file, "columns");
|
|
227
|
+
if (limits.maxCells !== undefined && at * widest > limits.maxCells) throw limitError("importCells", at * widest, limits.maxCells, file, "values (rows x columns)");
|
|
228
|
+
out.write(buf, lt, stop);
|
|
229
|
+
lastValueRow = Math.max(lastValueRow, at);
|
|
230
|
+
for (let e = indexOf(buf, ERROR_TYPE, tagEnd, end); e !== -1; e = indexOf(buf, ERROR_TYPE, e + 1, end)) errors++;
|
|
231
|
+
} else {
|
|
232
|
+
dropped++;
|
|
233
|
+
}
|
|
234
|
+
i = stop;
|
|
235
|
+
}
|
|
236
|
+
pending = i < buf.length ? buf.slice(i) : new Uint8Array(0);
|
|
237
|
+
if (pending.length > MAX_PENDING) {
|
|
238
|
+
throw fileError("DAMAGED", file, "a row of the sheet is larger than 64 MiB, or the sheet's XML is broken; the workbook is refused.");
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// pieces wait until they double what is pending, so a long row is scanned a few times, not once per piece
|
|
243
|
+
let waiting = [];
|
|
244
|
+
let size = 0;
|
|
245
|
+
let need = 0;
|
|
246
|
+
const gather = () => {
|
|
247
|
+
if (waiting.length === 1) return waiting[0];
|
|
248
|
+
const all = new Uint8Array(size);
|
|
249
|
+
let at = 0;
|
|
250
|
+
for (const w of waiting) {
|
|
251
|
+
all.set(w, at);
|
|
252
|
+
at += w.length;
|
|
253
|
+
}
|
|
254
|
+
return all;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
/** @param {Uint8Array} chunk */
|
|
259
|
+
push(chunk) {
|
|
260
|
+
waiting.push(chunk);
|
|
261
|
+
size += chunk.length;
|
|
262
|
+
if (size < need) return;
|
|
263
|
+
pending = gather();
|
|
264
|
+
run(false);
|
|
265
|
+
waiting = pending.length ? [pending] : [];
|
|
266
|
+
size = pending.length;
|
|
267
|
+
need = pending.length * 2;
|
|
268
|
+
},
|
|
269
|
+
/** @returns {{ lastRow: number, lastValueRow: number, dropped: number, errors: number }} */
|
|
270
|
+
end() {
|
|
271
|
+
pending = size ? gather() : new Uint8Array(0);
|
|
272
|
+
waiting = [];
|
|
273
|
+
run(true);
|
|
274
|
+
if (pending.length) out.write(pending);
|
|
275
|
+
return { lastRow, lastValueRow, dropped, errors };
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|