-
Notifications
You must be signed in to change notification settings - Fork 84
XLSX support with ExcelJS #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
a8f7998
38fceab
9226446
77159f0
d8904d0
ee0dfbf
fd177b0
f6ddcff
9b9eab6
a845086
f30b626
e421983
e8b0153
e7c82d4
1440400
d444ebe
410f4c9
57cb0e0
e2976b1
e618095
b97b9f6
e5eb8d6
81433c6
2f26284
66a539c
fcb6eb7
162d55e
9c9e91b
1a0345e
5daef26
92c4af1
6f13d59
c52b73f
5b21a79
0a59d0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
export class ExcelWorkbook { | ||
constructor(workbook) { | ||
Object.defineProperty(this, "_", {value: workbook}); | ||
} | ||
sheetNames() { | ||
return this._.worksheets.map((sheet) => sheet.name); | ||
} | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
sheet(name, {range, headers = false} = {}) { | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const sheet = this._.getWorksheet( | ||
typeof name === "number" ? this.sheetNames()[name] : name + "" | ||
); | ||
if (!sheet) throw new Error(`Sheet not found: ${name}`); | ||
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]) { | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let name = (headerRow ? valueOf(headerRow._cells[n]) : AA(n)) || AA(n); | ||
while (seen.has(name)) name += "_"; | ||
seen.add((names[n] = name)); | ||
} | ||
return names[n]; | ||
} | ||
if (headerRow) for (let c = c0; c <= c1; c++) name(c); | ||
|
||
const output = new Array(r1 - r0 + 1).fill({}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is filling the output with a shared empty object for all rows, whereas the rows with values are reassigned below to new objects. Do we want to use an empty object to represent rows without values, rather than undefined? If we do want to use an empty object, I think we’ll still want a distinct object for each row, rather than sharing the object across rows. That could be done by moving the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried to use sparseness/undefined initially but I slightly want to auto-filter these rows out of the return value since I feel like in usage that would be one of the first things I'd always end up writing anyway in the notebook, but that seemed like possibly surprising behavior at the same time? |
||
for (let r = r0; r <= r1; r++) { | ||
const _row = sheet._rows[r]; | ||
if (!_row || !_row.hasValues) continue; | ||
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; | ||
} | ||
} | ||
|
||
output.columns = names.filter(() => true); | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return output; | ||
} | ||
|
||
function valueOf(cell) { | ||
if (!cell) return; | ||
const {value} = cell; | ||
if (value && typeof value === "object") { | ||
if (value.formula) return value.result; | ||
if (value.richText) return value.richText.map((d) => d.text).join(""); | ||
if (value.text && value.hyperlink) | ||
return `<a href="${value.hyperlink}">${value.text}</a>`; | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return value; | ||
} | ||
|
||
function parseRange(specifier = [], {columnCount, rowCount}) { | ||
if (typeof specifier === "string") { | ||
const [ | ||
[c0 = 0, r0 = 0] = [], | ||
[c1 = columnCount - 1, r1 = rowCount - 1] = [], | ||
] = specifier.split(":").map(NN); | ||
return [ | ||
[c0, r0], | ||
[c1, r1], | ||
]; | ||
} else if (typeof specifier === "object") { | ||
const [ | ||
[c0 = 0, r0 = 0] = [], | ||
[c1 = columnCount - 1, r1 = rowCount - 1] = [], | ||
] = specifier; | ||
return [ | ||
[c0, r0], | ||
[c1, r1], | ||
]; | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
|
||
function AA(c) { | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let sc = ""; | ||
c++; | ||
do { | ||
sc = String.fromCharCode(64 + (c % 26 || 26)) + sc; | ||
} while ((c = Math.floor((c - 1) / 26))); | ||
return sc; | ||
} | ||
|
||
function NN(s = "") { | ||
const [, sc, sr] = s.match(/^([a-zA-Z]+)?(\d+)?$/); | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let c = undefined; | ||
if (sc) { | ||
c = 0; | ||
visnup marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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]; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
import {test} from "tap"; | ||
import {ExcelWorkbook} from "../src/xlsx.js"; | ||
|
||
function mockWorkbook(contents) { | ||
return { | ||
worksheets: Object.keys(contents).map((name) => ({name})), | ||
getWorksheet(name) { | ||
const _rows = contents[name]; | ||
return { | ||
_rows: _rows.map((row) => ({ | ||
_cells: row.map((cell) => ({value: cell})), | ||
hasValues: !!row.length, | ||
})), | ||
rowCount: _rows.length, | ||
columnCount: Math.max(..._rows.map((r) => r.length)), | ||
}; | ||
}, | ||
}; | ||
} | ||
|
||
test("FileAttachment.xlsx reads sheet names", (t) => { | ||
const workbook = new ExcelWorkbook(mockWorkbook({Sheet1: []})); | ||
t.same(workbook.sheetNames(), ["Sheet1"]); | ||
t.end(); | ||
}); | ||
|
||
test("FileAttachment.xlsx reads sheets", (t) => { | ||
const workbook = new ExcelWorkbook( | ||
mockWorkbook({ | ||
Sheet1: [ | ||
["one", "two", "three"], | ||
[1, 2, 3], | ||
], | ||
}) | ||
); | ||
t.same(workbook.sheet(0), [ | ||
{A: "one", B: "two", C: "three"}, | ||
{A: 1, B: 2, C: 3}, | ||
]); | ||
t.end(); | ||
}); | ||
|
||
test("FileAttachment.xlsx reads sheets with different types", (t) => { | ||
const workbook = new ExcelWorkbook( | ||
mockWorkbook({ | ||
Sheet1: [ | ||
["one", {richText: [{text: "two"}, {text: "three"}]}], | ||
[ | ||
{text: "link", hyperlink: "https://example.com"}, | ||
2, | ||
{formula: "=B2*5", result: 10}, | ||
], | ||
], | ||
}) | ||
); | ||
t.same(workbook.sheet(0), [ | ||
{A: "one", B: "twothree"}, | ||
{A: `<a href="https://example.com">link</a>`, B: 2, C: 10}, | ||
]); | ||
t.end(); | ||
}); | ||
|
||
test("FileAttachment.xlsx reads sheets with headers", (t) => { | ||
const workbook = new ExcelWorkbook( | ||
mockWorkbook({ | ||
Sheet1: [ | ||
[null, "one", "one", "two", "A"], | ||
[ 1, null, 3, 4, 5], | ||
[ 6, 7, 8, 9, 10], | ||
], | ||
}) | ||
); | ||
t.same(workbook.sheet(0, {headers: true}), [ | ||
{A: 1, one_: 3, two: 4, A_: 5}, | ||
{A: 6, one: 7, one_: 8, two: 9, A_: 10}, | ||
]); | ||
t.end(); | ||
}); | ||
|
||
test("FileAttachment.xlsx reads sheet ranges", (t) => { | ||
const workbook = new ExcelWorkbook( | ||
mockWorkbook({ | ||
Sheet1: [ | ||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], | ||
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19], | ||
[20, 21, 22, 23, 24, 25, 26, 27, 28, 29], | ||
[30, 31, 32, 33, 34, 35, 36, 37, 38, 39], | ||
], | ||
}) | ||
); | ||
|
||
// undefined | ||
// "" | ||
// [] | ||
const entireSheet = [ | ||
{A: 0, B: 1, C: 2, D: 3, E: 4, F: 5, G: 6, H: 7, I: 8, J: 9}, | ||
{A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, G: 16, H: 17, I: 18, J: 19}, | ||
{A: 20, B: 21, C: 22, D: 23, E: 24, F: 25, G: 26, H: 27, I: 28, J: 29}, | ||
{A: 30, B: 31, C: 32, D: 33, E: 34, F: 35, G: 36, H: 37, I: 38, J: 39}, | ||
]; | ||
t.same(workbook.sheet(0), entireSheet); | ||
t.same(workbook.sheet(0, {range: ""}), entireSheet); | ||
t.same(workbook.sheet(0, {range: []}), entireSheet); | ||
|
||
// "B2:C3" | ||
// [[1,1],[2,2]] | ||
t.same(workbook.sheet(0, {range: "B2:C3"}), [ | ||
{B: 11, C: 12}, | ||
{B: 21, C: 22}, | ||
]); | ||
t.same( | ||
workbook.sheet(0, { | ||
range: [ | ||
[1, 1], | ||
[2, 2], | ||
], | ||
}), | ||
[ | ||
{B: 11, C: 12}, | ||
{B: 21, C: 22}, | ||
] | ||
); | ||
|
||
// ":C3" | ||
// [,[2,2]] | ||
t.same(workbook.sheet(0, {range: ":C3"}), [ | ||
{A: 0, B: 1, C: 2}, | ||
{A: 10, B: 11, C: 12}, | ||
{A: 20, B: 21, C: 22}, | ||
]); | ||
t.same(workbook.sheet(0, {range: [undefined, [2, 2]]}), [ | ||
{A: 0, B: 1, C: 2}, | ||
{A: 10, B: 11, C: 12}, | ||
{A: 20, B: 21, C: 22}, | ||
]); | ||
|
||
// "B2" | ||
// [[1,1]] | ||
t.same(workbook.sheet(0, {range: "B2"}), [ | ||
{B: 11, C: 12, D: 13, E: 14, F: 15, G: 16, H: 17, I: 18, J: 19}, | ||
{B: 21, C: 22, D: 23, E: 24, F: 25, G: 26, H: 27, I: 28, J: 29}, | ||
{B: 31, C: 32, D: 33, E: 34, F: 35, G: 36, H: 37, I: 38, J: 39}, | ||
]); | ||
t.same(workbook.sheet(0, {range: [[1, 1]]}), [ | ||
{B: 11, C: 12, D: 13, E: 14, F: 15, G: 16, H: 17, I: 18, J: 19}, | ||
{B: 21, C: 22, D: 23, E: 24, F: 25, G: 26, H: 27, I: 28, J: 29}, | ||
{B: 31, C: 32, D: 33, E: 34, F: 35, G: 36, H: 37, I: 38, J: 39}, | ||
]); | ||
|
||
// "2" | ||
// [[,1]] | ||
t.same(workbook.sheet(0, {range: "2"}), entireSheet.slice(1)); | ||
t.same(workbook.sheet(0, {range: [[undefined, 1]]}), entireSheet.slice(1)); | ||
|
||
t.end(); | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.