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(); });