diff --git a/README.md b/README.md
index a5ffdffe..5a2387ee 100644
--- a/README.md
+++ b/README.md
@@ -379,6 +379,10 @@ Returns a promise to the file loads as a [SQLite database client](https://observ
const db = await FileAttachment("chinook.db").sqlite();
```
+# *attachment*.xlsx() [<>](https://github.com/observablehq/stdlib/blob/master/src/xlsx.js "Source")
+
+Returns a promise to the file loaded as an [ExcelWorkbook](https://observablehq.com/@observablehq/excelworkbook).
+
# FileAttachments(resolve) [<>](https://github.com/observablehq/stdlib/blob/master/src/fileAttachment.js "Source")
*Note: this function is not part of the Observable standard library (in notebooks), but is provided by this module as a means for defining custom file attachment implementations when working directly with the Observable runtime.*
diff --git a/package.json b/package.json
index f7de5be5..e92ee190 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"url": "https://github.com/observablehq/stdlib.git"
},
"scripts": {
- "test": "tap 'test/**/*-test.js'",
+ "test": "tap 'test/**/*-test.js' --reporter classic",
"prepublishOnly": "rollup -c",
"postpublish": "git push && git push --tags"
},
diff --git a/src/xlsx.js b/src/xlsx.js
index cc6528da..688232f3 100644
--- a/src/xlsx.js
+++ b/src/xlsx.js
@@ -6,28 +6,24 @@ export class ExcelWorkbook {
return this._.worksheets.map((sheet) => sheet.name);
}
sheet(name, {range, headers = false} = {}) {
- const sheet = this._.getWorksheet(
- typeof name === "number" ? this.sheetNames()[name] : name + ""
- );
- if (!sheet) throw new Error(`Sheet not found: ${name}`);
+ const names = this.sheetNames();
+ const sname = typeof name === "number" ? names[name] : names.includes(name + "") ? name + "" : null;
+ if (sname == null) throw new Error(`Sheet not found: ${name}`);
+ const sheet = this._.getWorksheet(sname);
return extract(sheet, {range, headers});
}
}
function extract(sheet, {range, headers}) {
let [[c0, r0], [c1, r1]] = parseRange(range, sheet);
- const seen = new Set();
- const names = [];
const headerRow = headers && sheet._rows[r0++];
- function name(n) {
- if (!names[n]) {
- let name = (headerRow ? valueOf(headerRow._cells[n]) : AA(n)) || AA(n);
- while (seen.has(name)) name += "_";
- seen.add((names[n] = name));
- }
- return names[n];
+ let names = new Set();
+ for (let n = c0; n <= c1; n++) {
+ let name = (headerRow ? valueOf(headerRow._cells[n]) : null) || AA(n);
+ while (names.has(name)) name += "_";
+ names.add(name);
}
- if (headerRow) for (let c = c0; c <= c1; c++) name(c);
+ names = new Array(c0).concat(Array.from(names));
const output = new Array(r1 - r0 + 1).fill({});
for (let r = r0; r <= r1; r++) {
@@ -36,7 +32,7 @@ function extract(sheet, {range, headers}) {
const row = (output[r - r0] = {});
for (let c = c0; c <= c1; c++) {
const value = valueOf(_row._cells[c]);
- if (value !== null && value !== undefined) row[name(c)] = value;
+ if (value != null) row[names[c]] = value;
}
}
@@ -75,6 +71,8 @@ function parseRange(specifier = [], {columnCount, rowCount}) {
[c0, r0],
[c1, r1],
];
+ } else {
+ throw new Error(`Unknown range specifier`);
}
}
@@ -87,13 +85,13 @@ function AA(c) {
return sc;
}
-function NN(s = "") {
- const [, sc, sr] = s.match(/^([a-zA-Z]+)?(\d+)?$/);
+function NN(s) {
+ const [, sc, sr] = s.match(/^([A-Z]*)(\d*)$/i);
let c = undefined;
if (sc) {
c = 0;
for (let i = 0; i < sc.length; i++)
c += Math.pow(26, sc.length - i - 1) * (sc.charCodeAt(i) - 64);
}
- return [c && c - 1, sr && +sr - 1];
+ return [c ? c - 1 : undefined, sr ? +sr - 1 : undefined];
}
diff --git a/test/xlsx-test.js b/test/xlsx-test.js
index 60a622ba..40ef8999 100644
--- a/test/xlsx-test.js
+++ b/test/xlsx-test.js
@@ -24,6 +24,12 @@ test("FileAttachment.xlsx reads sheet names", (t) => {
t.end();
});
+test("FileAttachment.xlsx sheet(name) throws on unknown sheet name", (t) => {
+ const workbook = new ExcelWorkbook(mockWorkbook({Sheet1: []}));
+ t.throws(() => workbook.sheet("bad"));
+ t.end();
+});
+
test("FileAttachment.xlsx reads sheets", (t) => {
const workbook = new ExcelWorkbook(
mockWorkbook({
@@ -37,6 +43,10 @@ test("FileAttachment.xlsx reads sheets", (t) => {
{A: "one", B: "two", C: "three"},
{A: 1, B: 2, C: 3},
]);
+ t.same(workbook.sheet("Sheet1"), [
+ {A: "one", B: "two", C: "three"},
+ {A: 1, B: 2, C: 3},
+ ]);
t.end();
});
@@ -44,18 +54,20 @@ test("FileAttachment.xlsx reads sheets with different types", (t) => {
const workbook = new ExcelWorkbook(
mockWorkbook({
Sheet1: [
- ["one", {richText: [{text: "two"}, {text: "three"}]}],
+ ["one", null, {richText: [{text: "two"}, {text: "three"}]}, undefined],
[
{text: "link", hyperlink: "https://example.com"},
2,
{formula: "=B2*5", result: 10},
],
+ [],
],
})
);
t.same(workbook.sheet(0), [
- {A: "one", B: "twothree"},
+ {A: "one", C: "twothree"},
{A: `link`, B: 2, C: 10},
+ {},
]);
t.end();
});
@@ -152,5 +164,42 @@ test("FileAttachment.xlsx reads sheet ranges", (t) => {
t.same(workbook.sheet(0, {range: "2"}), entireSheet.slice(1));
t.same(workbook.sheet(0, {range: [[undefined, 1]]}), entireSheet.slice(1));
+ // ":I"
+ // [,[1,]]
+ const sheetJ = [
+ { I: 8, J: 9 },
+ { I: 18, J: 19 },
+ { I: 28, J: 29 },
+ { I: 38, J: 39 }
+ ];
+ t.same(workbook.sheet(0, {range: "I"}), sheetJ);
+ t.same(workbook.sheet(0, {range: [[8, undefined], undefined]}), sheetJ);
+ t.end();
+});
+
+test("FileAttachment.xlsx throws on unknown range specifier", (t) => {
+ const workbook = new ExcelWorkbook(mockWorkbook({ Sheet1: [] }));
+ t.throws(() => workbook.sheet(0, {range: 0}));
+ t.end();
+});
+
+test("FileAttachment.xlsx derives column names such as A AA AAA…", (t) => {
+ const l0 = 26 * 26 * 26 + 26 * 26 + 26;
+ const workbook = new ExcelWorkbook(
+ mockWorkbook({
+ Sheet1: [
+ Array.from({length: l0}).fill(1),
+ ],
+ })
+ );
+ t.same(workbook.sheet(0, {headers: false}).columns.filter(d => d.match(/^A*$/)), ["A", "AA", "AAA"]);
+ const workbook1 = new ExcelWorkbook(
+ mockWorkbook({
+ Sheet1: [
+ Array.from({length: l0 + 1}).fill(1),
+ ],
+ })
+ );
+ t.same(workbook1.sheet(0, {headers: false}).columns.filter(d => d.match(/^A*$/)), ["A", "AA", "AAA", "AAAA"]);
t.end();
});