79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
// Minimal xlsx reader for the stats collector (no dependency): unzips the workbook with the
|
|
// host's `unzip` and reads cell values from the sheet XML. Enough for hand-formatted statistical
|
|
// tables: returns each sheet as a grid of strings/numbers (merged cells are not expanded).
|
|
export type Cell = string | number | null;
|
|
export type Sheet = { name: string; rows: Cell[][] };
|
|
|
|
async function unzipEntry(path: string, entry: string): Promise<string | null> {
|
|
const proc = Bun.spawn(['unzip', '-p', path, entry], { stdout: 'pipe', stderr: 'ignore' });
|
|
const text = await new Response(proc.stdout).text();
|
|
return (await proc.exited) === 0 ? text : null;
|
|
}
|
|
|
|
const decode = (s: string) =>
|
|
s
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
.replace(/&/g, '&');
|
|
|
|
/** Text of all <t> runs inside a fragment (shared strings and inline strings). */
|
|
const textOf = (xml: string) =>
|
|
decode([...xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)].map((m) => m[1]).join(''));
|
|
|
|
function colIndex(ref: string) {
|
|
let n = 0;
|
|
for (const ch of ref.replace(/\d+$/, '')) n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
return n - 1;
|
|
}
|
|
|
|
export async function readXlsx(path: string): Promise<Sheet[]> {
|
|
const workbook = await unzipEntry(path, 'xl/workbook.xml');
|
|
const rels = await unzipEntry(path, 'xl/_rels/workbook.xml.rels');
|
|
if (!workbook || !rels) throw new Error('not an xlsx workbook');
|
|
const shared = await unzipEntry(path, 'xl/sharedStrings.xml');
|
|
const strings = shared
|
|
? [...shared.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => textOf(m[1]))
|
|
: [];
|
|
const target = new Map(
|
|
[...rels.matchAll(/<Relationship\b[^>]*>/g)].map((m) => [
|
|
m[0].match(/Id="([^"]+)"/)?.[1],
|
|
m[0].match(/Target="([^"]+)"/)?.[1]
|
|
])
|
|
);
|
|
const sheets: Sheet[] = [];
|
|
for (const m of workbook.matchAll(/<sheet\b[^>]*>/g)) {
|
|
const name = decode(m[0].match(/name="([^"]*)"/)?.[1] ?? '');
|
|
const rid = m[0].match(/r:id="([^"]+)"/)?.[1];
|
|
let file = target.get(rid) ?? '';
|
|
file = file.startsWith('/') ? file.slice(1) : `xl/${file}`;
|
|
const xml = await unzipEntry(path, file);
|
|
if (!xml) continue;
|
|
const rows: Cell[][] = [];
|
|
for (const r of xml.matchAll(/<row\b[^>]*?r="(\d+)"[^>]*?(?:\/>|>([\s\S]*?)<\/row>)/g)) {
|
|
const row: Cell[] = [];
|
|
for (const c of (r[2] ?? '').matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
|
const ref = c[1].match(/r="([A-Z]+\d+)"/)?.[1];
|
|
if (!ref) continue;
|
|
const type = c[1].match(/t="(\w+)"/)?.[1];
|
|
const body = c[2] ?? '';
|
|
const v = body.match(/<v>([\s\S]*?)<\/v>/)?.[1];
|
|
let value: Cell = null;
|
|
if (type === 's' && v != null) value = strings[Number(v)] ?? null;
|
|
else if (type === 'inlineStr') value = textOf(body);
|
|
else if (type === 'str' || type === 'e') value = v != null ? decode(v) : null;
|
|
else if (type === 'b') value = v === '1' ? 'TRUE' : 'FALSE';
|
|
else if (v != null && v !== '') value = Number(v);
|
|
if (typeof value === 'string') value = value.trim() || null;
|
|
row[colIndex(ref)] = value;
|
|
}
|
|
rows[Number(r[1]) - 1] = Array.from(row, (x) => x ?? null);
|
|
}
|
|
sheets.push({ name, rows: Array.from(rows, (x) => x ?? []) });
|
|
}
|
|
return sheets;
|
|
}
|