Skip to content

Commit db58b85

Browse files
visnupFilmbostock
authored
XLSX support with ExcelJS (#248)
* XLSX support with ExcelJS * Prettier * Change range option to nested arrays General code clean up * Tests and bug fixes * Respect header row order when resolving conflicts * Fil/xlsx (#249) * document xlsx (minimalist, we'll work on the notebook first) * fix coverage reporter (avoids a crash on my computer; solution found at tapjs/tapjs#624) * unknown sheet name * simplify rows naming * NN is always called on string (cell specifier such as "AA99") * test name * more range specifiers * Column only range test case * sheetNames is enumerable * One more test to check for empty columns Prettier + use default/base tap reporter * Add Node 16 to the test matrix * Revert reporter to classic for Node 16 * Don't fail matrix quickly in actions * More coverage. * Example of .xlsx in README * Remove Excel from Workbook naming * Fix dates * Fix for sharedFormula * Coerce errors to NaN * Properly escape html * Make sheetNames read-only * Require colons in range specifiers * Include row numbers * Use only string form ranges * Coerce range specifiers to strings * Update README.md Co-authored-by: Mike Bostock <[email protected]> * Apply suggestions from code review Co-authored-by: Mike Bostock <[email protected]> * Simplify hyperlinks * Prettier * Pass options through * Rename helper functions for clarity, range tests * Simpler * Consistent comment format * Consistent regexes * Fix hyperlinks for certain cases Co-authored-by: Philippe Rivière <[email protected]> Co-authored-by: Mike Bostock <[email protected]>
1 parent 6cfe135 commit db58b85

File tree

7 files changed

+412
-15
lines changed

7 files changed

+412
-15
lines changed

.github/workflows/nodejs.yml

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,23 @@ on: [push]
44

55
jobs:
66
build:
7-
87
strategy:
8+
fail-fast: false
99
matrix:
1010
os: [ubuntu-latest]
11-
node-version: [12.x, 14.x]
11+
node-version: [12.x, 14.x, 16.x]
1212

1313
runs-on: ${{ matrix.os }}
1414

1515
steps:
16-
- uses: actions/checkout@v1
17-
- name: Use Node.js ${{ matrix.node-version }}
18-
uses: actions/setup-node@v1
19-
with:
20-
node-version: ${{ matrix.node-version }}
21-
- name: yarn install and test
22-
run: |
23-
yarn install --frozen-lockfile
24-
yarn test
25-
env:
26-
CI: true
16+
- uses: actions/checkout@v1
17+
- name: Use Node.js ${{ matrix.node-version }}
18+
uses: actions/setup-node@v1
19+
with:
20+
node-version: ${{ matrix.node-version }}
21+
- name: yarn install and test
22+
run: |
23+
yarn install --frozen-lockfile
24+
yarn test
25+
env:
26+
CI: true

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,15 @@ Returns a promise to the file loaded as a [SQLite database client](https://obser
379379
const db = await FileAttachment("chinook.db").sqlite();
380380
```
381381

382+
<a href="#attachment_xlsx" name="attachment_xlsx">#</a> *attachment*.<b>xlsx</b>() [<>](https://github.com/observablehq/stdlib/blob/master/src/xlsx.js "Source")
383+
384+
Returns a promise to the file loaded as a [Workbook](https://observablehq.com/@observablehq/xlsx).
385+
386+
```js
387+
const workbook = await FileAttachment("profit-and-loss.xlsx").xlsx();
388+
const sheet = workbook.sheet("Sheet1", {range: "B4:AF234", headers: true});
389+
```
390+
382391
<a href="#attachment_xml" name="attachment_xml">#</a> *attachment*.<b>xml</b>() [<>](https://github.com/observablehq/stdlib/blob/master/src/fileAttachment.js "Source")
383392

384393
Returns a promise to an [XMLDocument](https://developer.mozilla.org/en-US/docs/Web/API/XMLDocument) containing the contents of the file.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"url": "https://github.com/observablehq/stdlib.git"
1414
},
1515
"scripts": {
16-
"test": "tap 'test/**/*-test.js'",
16+
"test": "tap 'test/**/*-test.js' --reporter classic",
1717
"prepublishOnly": "rollup -c",
1818
"postpublish": "git push && git push --tags"
1919
},

src/dependencies.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export const vegaliteApi = dependency("vega-lite-api", "5.0.0", "build/vega-lite
1616
export const arrow = dependency("apache-arrow", "4.0.1", "Arrow.es2015.min.js");
1717
export const arquero = dependency("arquero", "4.8.4", "dist/arquero.min.js");
1818
export const topojson = dependency("topojson-client", "3.1.0", "dist/topojson-client.min.js");
19+
export const exceljs = dependency("exceljs", "4.3.0", "dist/exceljs.min.js");

src/fileAttachment.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {autoType, csvParse, csvParseRows, tsvParse, tsvParseRows} from "d3-dsv";
22
import {require as requireDefault} from "d3-require";
3-
import {arrow, jszip} from "./dependencies.js";
3+
import {arrow, jszip, exceljs} from "./dependencies.js";
44
import {SQLiteDatabaseClient} from "./sqlite.js";
5+
import {Workbook} from "./xlsx.js";
56

67
async function remote_fetch(file) {
78
const response = await fetch(await file.url());
@@ -70,6 +71,10 @@ class AbstractFile {
7071
async html() {
7172
return this.xml("text/html");
7273
}
74+
async xlsx() {
75+
const [ExcelJS, buffer] = await Promise.all([requireDefault(exceljs.resolve()), this.arrayBuffer()]);
76+
return new Workbook(await new ExcelJS.Workbook().xlsx.load(buffer));
77+
}
7378
}
7479

7580
class FileAttachment extends AbstractFile {

src/xlsx.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
export class Workbook {
2+
constructor(workbook) {
3+
Object.defineProperties(this, {
4+
_: {value: workbook},
5+
sheetNames: {
6+
value: workbook.worksheets.map((s) => s.name),
7+
enumerable: true,
8+
},
9+
});
10+
}
11+
sheet(name, options) {
12+
const sname =
13+
typeof name === "number"
14+
? this.sheetNames[name]
15+
: this.sheetNames.includes((name += ""))
16+
? name
17+
: null;
18+
if (sname == null) throw new Error(`Sheet not found: ${name}`);
19+
const sheet = this._.getWorksheet(sname);
20+
return extract(sheet, options);
21+
}
22+
}
23+
24+
function extract(sheet, {range, headers = false} = {}) {
25+
let [[c0, r0], [c1, r1]] = parseRange(range, sheet);
26+
const headerRow = headers && sheet._rows[r0++];
27+
let names = new Set(["#"]);
28+
for (let n = c0; n <= c1; n++) {
29+
let name = (headerRow ? valueOf(headerRow._cells[n]) : null) || toColumn(n);
30+
while (names.has(name)) name += "_";
31+
names.add(name);
32+
}
33+
names = new Array(c0).concat(Array.from(names));
34+
35+
const output = new Array(r1 - r0 + 1);
36+
for (let r = r0; r <= r1; r++) {
37+
const row = (output[r - r0] = Object.defineProperty({}, "#", {
38+
value: r + 1,
39+
}));
40+
const _row = sheet._rows[r];
41+
if (_row && _row.hasValues)
42+
for (let c = c0; c <= c1; c++) {
43+
const value = valueOf(_row._cells[c]);
44+
if (value != null) row[names[c + 1]] = value;
45+
}
46+
}
47+
48+
output.columns = names.filter(() => true); // Filter sparse columns
49+
return output;
50+
}
51+
52+
function valueOf(cell) {
53+
if (!cell) return;
54+
const {value} = cell;
55+
if (value && value instanceof Date) return value;
56+
if (value && typeof value === "object") {
57+
if (value.formula || value.sharedFormula)
58+
return value.result && value.result.error ? NaN : value.result;
59+
if (value.richText) return value.richText.map((d) => d.text).join("");
60+
if (value.text) {
61+
let {text} = value;
62+
if (text.richText) text = text.richText.map((d) => d.text).join("");
63+
return value.hyperlink && value.hyperlink !== text
64+
? `${value.hyperlink} ${text}`
65+
: text;
66+
}
67+
return value;
68+
}
69+
return value;
70+
}
71+
72+
function parseRange(specifier = ":", {columnCount, rowCount}) {
73+
specifier += "";
74+
if (!specifier.match(/^[A-Z]*\d*:[A-Z]*\d*$/))
75+
throw new Error("Malformed range specifier");
76+
const [[c0 = 0, r0 = 0], [c1 = columnCount - 1, r1 = rowCount - 1]] =
77+
specifier.split(":").map(fromCellReference);
78+
return [
79+
[c0, r0],
80+
[c1, r1],
81+
];
82+
}
83+
84+
// Returns the default column name for a zero-based column index.
85+
// For example: 0 -> "A", 1 -> "B", 25 -> "Z", 26 -> "AA", 27 -> "AB".
86+
function toColumn(c) {
87+
let sc = "";
88+
c++;
89+
do {
90+
sc = String.fromCharCode(64 + (c % 26 || 26)) + sc;
91+
} while ((c = Math.floor((c - 1) / 26)));
92+
return sc;
93+
}
94+
95+
// Returns the zero-based indexes from a cell reference.
96+
// For example: "A1" -> [0, 0], "B2" -> [1, 1], "AA10" -> [26, 9].
97+
function fromCellReference(s) {
98+
const [, sc, sr] = s.match(/^([A-Z]*)(\d*)$/);
99+
let c = 0;
100+
if (sc)
101+
for (let i = 0; i < sc.length; i++)
102+
c += Math.pow(26, sc.length - i - 1) * (sc.charCodeAt(i) - 64);
103+
return [c ? c - 1 : undefined, sr ? +sr - 1 : undefined];
104+
}

0 commit comments

Comments
 (0)