pivotkit-xlsx 0.0.0-stage → 0.2.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 +60 -0
- package/LICENSE +21 -0
- package/README.md +42 -2
- package/dist/THIRD_PARTY_LICENSES.txt +85 -0
- package/dist/chunks/chunk-KDJGTIXW.js +1 -0
- package/dist/chunks/chunk-TEWBHPQ4.js +1 -0
- package/dist/chunks/import-csv-TRZA6M7W.js +1 -0
- package/dist/chunks/import-json-YR5ADN4H.js +1 -0
- package/dist/chunks/reader-S5MTRZAR.js +9 -0
- package/dist/index.js +1 -0
- package/package.json +66 -4
- package/src/index.js +21 -0
- package/src/reader.js +231 -0
- package/src/workbook.js +79 -0
- package/src/xml.js +593 -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 +84 -0
- package/types/zip.d.ts +55 -0
package/src/reader.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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, streaming, never beyond the size the directory
|
|
11
|
+
* states; it leaves out the sheet's rows without a value (the used range of formatted empty cells)
|
|
12
|
+
* and what follows them, cuts long runs of whitespace and refuses a text over 1 MiB or a tag over 256 KiB, which
|
|
13
|
+
* read-excel-file would parse in quadratic time (xml.js).
|
|
14
|
+
* 3. read-excel-file (MIT) parses those parts: shared strings, styles (a date is a number with a date
|
|
15
|
+
* format), the values Excel saved (never a formula). It gets them as a ZIP without compression,
|
|
16
|
+
* so it unpacks nothing itself and starts no worker: no `blob:` worker, which a strict
|
|
17
|
+
* Content-Security-Policy would block.
|
|
18
|
+
* 4. pivotkit-core's `createTable` finds the header, cleans the values and infers the types, as for
|
|
19
|
+
* CSV and JSON.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { bytesOf, cleanText, createTable, fileError, fileName, readOptions } from "pivotkit-core/import";
|
|
23
|
+
import { readSheet } from "read-excel-file/universal";
|
|
24
|
+
import { checkXml, createSheetFilter, createXmlCheck, createXmlGuard } from "./xml.js";
|
|
25
|
+
import { createStoredZip, readDirectory, unpack, unpackStream } from "./zip.js";
|
|
26
|
+
import { sharedParts, worksheets } from "./workbook.js";
|
|
27
|
+
|
|
28
|
+
const CFB = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
29
|
+
const ZIP = [0x50, 0x4b, 0x03, 0x04];
|
|
30
|
+
const startsWith = (bytes, sig) => sig.every((b, i) => bytes[i] === b);
|
|
31
|
+
/** A sheet name in a message: it comes from the file. */
|
|
32
|
+
const shown = (name) => cleanText(String(name)).slice(0, 100);
|
|
33
|
+
/** The most a workbook's list of sheets and its relationships may be; real ones are a few kilobytes. */
|
|
34
|
+
const MAX_WORKBOOK_PART = 4 * 1024 * 1024;
|
|
35
|
+
const ascii = (s) => Array.from(s, (c) => c.charCodeAt(0));
|
|
36
|
+
// a password-protected .xlsx is a compound file holding the encrypted package (UTF-16 stream name)
|
|
37
|
+
const ENCRYPTED_STREAM = ascii("EncryptedPackage").flatMap((c) => [c, 0]);
|
|
38
|
+
|
|
39
|
+
function containsBytes(bytes, pattern) {
|
|
40
|
+
const first = pattern[0];
|
|
41
|
+
for (let i = bytes.indexOf(first); i !== -1 && i <= bytes.length - pattern.length; i = bytes.indexOf(first, i + 1)) {
|
|
42
|
+
let k = 1;
|
|
43
|
+
while (k < pattern.length && bytes[i + k] === pattern[k]) k++;
|
|
44
|
+
if (k === pattern.length) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Refuses what is no .xlsx, with what it is and what to do. */
|
|
50
|
+
function checkKind(bytes, file) {
|
|
51
|
+
if (/\.xlsm$/i.test(file)) {
|
|
52
|
+
throw fileError("MACROS", file, "a workbook with macros (.xlsm) is not loaded; save it as an Excel workbook (.xlsx) or as CSV.");
|
|
53
|
+
}
|
|
54
|
+
if (startsWith(bytes, CFB)) {
|
|
55
|
+
if (containsBytes(bytes, ENCRYPTED_STREAM)) {
|
|
56
|
+
throw fileError("ENCRYPTED", file, "the workbook is protected with a password; save it without one, then load it.");
|
|
57
|
+
}
|
|
58
|
+
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.");
|
|
59
|
+
}
|
|
60
|
+
if (!startsWith(bytes, ZIP)) {
|
|
61
|
+
throw fileError("WRONG_TYPE", file, "this is no Excel workbook (.xlsx); check the file, or load a CSV file with the CSV reader.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads and checks a workbook's directory and its small parts.
|
|
67
|
+
* @returns {Promise<{ entries: Map<string, import("./zip.js").ZipEntry>, parts: Map<string, Uint8Array>, sheets: ReturnType<typeof worksheets>, shared: ReturnType<typeof sharedParts> }>}
|
|
68
|
+
*/
|
|
69
|
+
async function open(bytes, file, limits, between) {
|
|
70
|
+
checkKind(bytes, file);
|
|
71
|
+
const list = readDirectory(bytes, file, limits);
|
|
72
|
+
const entries = new Map(list.map((e) => [e.name, e]));
|
|
73
|
+
for (const e of list) {
|
|
74
|
+
if (/(^|\/)vbaProject\.bin$/i.test(e.name)) {
|
|
75
|
+
throw fileError("MACROS", file, "the workbook holds macros (vbaProject.bin); save it as an Excel workbook (.xlsx) without macros, or as CSV.");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!entries.has("xl/workbook.xml")) {
|
|
79
|
+
if (entries.has("xl/workbook.bin")) {
|
|
80
|
+
throw fileError("XLSB", file, "this is an Excel binary workbook (.xlsb); save it as an Excel workbook (.xlsx) or as CSV, then load it.");
|
|
81
|
+
}
|
|
82
|
+
throw fileError("WRONG_TYPE", file, "this ZIP file is no Excel workbook (it has no xl/workbook.xml).");
|
|
83
|
+
}
|
|
84
|
+
/** @type {Map<string, Uint8Array>} */
|
|
85
|
+
const parts = new Map();
|
|
86
|
+
const read = async (path, max = Infinity) => {
|
|
87
|
+
const entry = entries.get(path);
|
|
88
|
+
if (!entry) return null;
|
|
89
|
+
if (entry.size > max) {
|
|
90
|
+
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.`);
|
|
91
|
+
}
|
|
92
|
+
const data = await unpack(bytes, entry, file, between);
|
|
93
|
+
checkXml(data, file, path);
|
|
94
|
+
parts.set(path, data);
|
|
95
|
+
return data;
|
|
96
|
+
};
|
|
97
|
+
const types = await read("[Content_Types].xml", MAX_WORKBOOK_PART);
|
|
98
|
+
if (types && /macroEnabled/i.test(new TextDecoder().decode(types))) {
|
|
99
|
+
throw fileError("MACROS", file, "a workbook with macros (.xlsm) is not loaded; save it as an Excel workbook (.xlsx) or as CSV.");
|
|
100
|
+
}
|
|
101
|
+
const decode = (data) => (data ? new TextDecoder().decode(data) : "");
|
|
102
|
+
const workbook = decode(await read("xl/workbook.xml", MAX_WORKBOOK_PART));
|
|
103
|
+
const rels = decode(await read("xl/_rels/workbook.xml.rels", MAX_WORKBOOK_PART));
|
|
104
|
+
const sheets = worksheets(workbook, rels);
|
|
105
|
+
if (sheets.length === 0) {
|
|
106
|
+
throw fileError("NO_TABLE", file, "the workbook has no worksheet.");
|
|
107
|
+
}
|
|
108
|
+
return { entries, parts, sheets, shared: sharedParts(rels) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The sheet asked for: by name, by index (0-based), else the first visible one. */
|
|
112
|
+
function pickSheet(sheets, wanted, file) {
|
|
113
|
+
if (typeof wanted === "string") {
|
|
114
|
+
const s = sheets.find((x) => x.name === wanted);
|
|
115
|
+
if (!s) throw fileError("NO_SHEET", file, `the workbook has no sheet "${shown(wanted)}".`, { sheet: wanted });
|
|
116
|
+
return s;
|
|
117
|
+
}
|
|
118
|
+
if (typeof wanted === "number") {
|
|
119
|
+
const s = sheets[wanted];
|
|
120
|
+
if (!s) throw fileError("NO_SHEET", file, `the workbook has no sheet ${wanted + 1}.`, { sheet: wanted });
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
return sheets.find((s) => s.visible) ?? sheets[0];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The worksheets of a workbook: name, index (0-based) and whether it is visible.
|
|
128
|
+
* @param {Blob | ArrayBuffer | Uint8Array} file
|
|
129
|
+
* @param {import("pivotkit-core/import").ReadOptions} [options]
|
|
130
|
+
* @returns {Promise<import("pivotkit-core/import").SheetInfo[]>}
|
|
131
|
+
*/
|
|
132
|
+
export async function sheets(file, options) {
|
|
133
|
+
const o = readOptions(options);
|
|
134
|
+
const name = fileName(file, o);
|
|
135
|
+
const bytes = await bytesOf(file, o.limits, name);
|
|
136
|
+
const { sheets: list } = await open(bytes, name, o.limits, () => o.signal?.throwIfAborted());
|
|
137
|
+
return list.map(({ name: sheet, index, visible }) => ({ name: sheet, index, visible }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reads one sheet of a workbook into a table: `options.sheet` (a name, or an index from 0), else the
|
|
142
|
+
* first visible one.
|
|
143
|
+
* @param {Blob | ArrayBuffer | Uint8Array} file
|
|
144
|
+
* @param {import("pivotkit-core/import").ReadOptions} [options]
|
|
145
|
+
* @returns {Promise<import("pivotkit-core/import").ImportTable>}
|
|
146
|
+
*/
|
|
147
|
+
export async function read(file, options) {
|
|
148
|
+
const o = readOptions(options);
|
|
149
|
+
const name = fileName(file, o);
|
|
150
|
+
const signal = o.signal;
|
|
151
|
+
signal?.throwIfAborted();
|
|
152
|
+
const bytes = await bytesOf(file, o.limits, name);
|
|
153
|
+
o.onProgress?.({ phase: "read", done: bytes.length, total: bytes.length });
|
|
154
|
+
const between = () => signal?.throwIfAborted();
|
|
155
|
+
const { entries, parts, sheets: list, shared } = await open(bytes, name, o.limits, between);
|
|
156
|
+
const sheet = pickSheet(list, o.sheet, name);
|
|
157
|
+
|
|
158
|
+
const entry = entries.get(sheet.path);
|
|
159
|
+
if (!entry) throw fileError("DAMAGED", name, `the part of sheet "${shown(sheet.name)}" is missing; open the workbook in Excel and save it again.`);
|
|
160
|
+
// shared strings and styles stream from the workbook into the ZIP below, never held whole
|
|
161
|
+
const streamed = [shared.sharedStrings, shared.styles].filter((path, k, all) => entries.has(path) && !parts.has(path) && all.indexOf(path) === k && path !== sheet.path);
|
|
162
|
+
// read-excel-file reads the parts from a ZIP: one without compression, of only these parts, each
|
|
163
|
+
// passed through createXmlGuard, with the sheet's rows without a value left out while it streams in
|
|
164
|
+
// sized for the parts as the directory states them: the buffer never grows (no copy); what the
|
|
165
|
+
// filter and the guard leave out is never written, and transfer() below gives the unused end back
|
|
166
|
+
let room = entry.size + 22 + 2 * (76 + sheet.path.length);
|
|
167
|
+
for (const [path, data] of parts) room += data.length + 2 * (76 + path.length);
|
|
168
|
+
for (const path of streamed) room += /** @type {import("./zip.js").ZipEntry} */ (entries.get(path)).size + 2 * (76 + path.length);
|
|
169
|
+
const zip = createStoredZip(room);
|
|
170
|
+
for (const [path, data] of parts) {
|
|
171
|
+
const part = zip.begin(path);
|
|
172
|
+
createXmlGuard(part, name, path).write(data);
|
|
173
|
+
part.end();
|
|
174
|
+
}
|
|
175
|
+
parts.clear();
|
|
176
|
+
for (const path of streamed) {
|
|
177
|
+
const part = zip.begin(path);
|
|
178
|
+
const guard = createXmlGuard(part, name, path);
|
|
179
|
+
const check = createXmlCheck(name, path);
|
|
180
|
+
await unpackStream(bytes, /** @type {import("./zip.js").ZipEntry} */ (entries.get(path)), name, (chunk) => {
|
|
181
|
+
check(chunk);
|
|
182
|
+
guard.write(chunk);
|
|
183
|
+
}, between);
|
|
184
|
+
part.end();
|
|
185
|
+
}
|
|
186
|
+
const sheetPart = zip.begin(sheet.path);
|
|
187
|
+
// a title block above the header may hold a few rows more than the table
|
|
188
|
+
const filter = createSheetFilter(createXmlGuard(sheetPart, name, sheet.path), { file: name, part: sheet.path, maxRow: o.limits.rows + 1_000, maxColumn: o.limits.columns, maxCells: o.limits.importCells + o.limits.columns * 1_000 });
|
|
189
|
+
const check = createXmlCheck(name, sheet.path);
|
|
190
|
+
let unpacked = 0;
|
|
191
|
+
await unpackStream(bytes, entry, name, (chunk) => {
|
|
192
|
+
check(chunk);
|
|
193
|
+
filter.push(chunk);
|
|
194
|
+
unpacked += chunk.length;
|
|
195
|
+
}, () => {
|
|
196
|
+
signal?.throwIfAborted();
|
|
197
|
+
o.onProgress?.({ phase: "unpack", done: unpacked, total: entry.size });
|
|
198
|
+
});
|
|
199
|
+
const trimmed = filter.end();
|
|
200
|
+
sheetPart.end();
|
|
201
|
+
let stored = zip.finish();
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
o.onProgress?.({ phase: "parse", done: 0, total: trimmed.lastValueRow });
|
|
204
|
+
/** @type {unknown[][]} */
|
|
205
|
+
let rows;
|
|
206
|
+
try {
|
|
207
|
+
// an ArrayBuffer of the ZIP's length: transfer() shrinks the buffer, where it can, without a copy
|
|
208
|
+
const whole = /** @type {ArrayBuffer & { transfer?: (length: number) => ArrayBuffer }} */ (stored.buffer);
|
|
209
|
+
const buffer = whole.byteLength === stored.length ? whole : typeof whole.transfer === "function" ? whole.transfer(stored.length) : stored.slice().buffer;
|
|
210
|
+
stored = null;
|
|
211
|
+
rows = await readSheet(buffer, sheet.name);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
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.`);
|
|
214
|
+
}
|
|
215
|
+
signal?.throwIfAborted();
|
|
216
|
+
o.onProgress?.({ phase: "parse", done: trimmed.lastValueRow, total: trimmed.lastValueRow });
|
|
217
|
+
|
|
218
|
+
const table = createTable({ file: name, limits: o.limits, locale: o.locale, hints: { decimal: ".", serialDates: true } });
|
|
219
|
+
table.note("SHEET", { sheet: sheet.name, count: list.length, visible: list.filter((s) => s.visible).length });
|
|
220
|
+
if (trimmed.lastRow > trimmed.lastValueRow && trimmed.lastRow - trimmed.lastValueRow >= 100) {
|
|
221
|
+
table.note("USED_RANGE", { usedRow: trimmed.lastRow, lastRow: trimmed.lastValueRow });
|
|
222
|
+
}
|
|
223
|
+
for (let r = 0; r < rows.length; r++) {
|
|
224
|
+
table.row(rows[r]);
|
|
225
|
+
rows[r] = null;
|
|
226
|
+
}
|
|
227
|
+
// read-excel-file reads a cell with an error (#N/A, #DIV/0!) as empty; the filter counted them
|
|
228
|
+
if (trimmed.errors > 0) table.note("ERROR_CELLS", { count: trimmed.errors });
|
|
229
|
+
signal?.throwIfAborted();
|
|
230
|
+
return table.finish(o.types);
|
|
231
|
+
}
|
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
|
+
}
|