@qe-mcp/ena 0.0.0-stage → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/xlsx.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * xlsx.js — read the first worksheet of an .xlsx file as CSV text, as the ENA
3
+ * Web Tool does for an uploaded .xlsx (rENA-webtool js/XlsxReader.js, ported
4
+ * to Node: zlib instead of JSZip, jsdom's DOMParser instead of the browser's).
5
+ *
6
+ * Parity with the web tool, and so with R's openxlsx::read.xlsx(sheet = 1):
7
+ * - first sheet in tab order only; its first row is the header row
8
+ * - values as stored: dates stay Excel serial numbers (no date detection)
9
+ * - booleans as TRUE / FALSE; shared and inline strings resolved
10
+ * - rows are taken in file order and padded to the widest row
11
+ */
12
+
13
+ import { inflateRawSync } from 'zlib';
14
+ import { JSDOM } from 'jsdom';
15
+
16
+ // ── ZIP: the entries of an .xlsx (a ZIP of XML parts) ────────────────────────
17
+ function unzip(buf) {
18
+ // End of central directory: scan back over a possible archive comment.
19
+ let eocd = -1;
20
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--)
21
+ if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
22
+ if (eocd < 0) throw new Error('not an .xlsx file (no ZIP directory found)');
23
+ const count = buf.readUInt16LE(eocd + 10);
24
+ let p = buf.readUInt32LE(eocd + 16);
25
+ const files = new Map();
26
+ for (let n = 0; n < count; n++) {
27
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('corrupt .xlsx (bad ZIP directory)');
28
+ const method = buf.readUInt16LE(p + 10);
29
+ const size = buf.readUInt32LE(p + 20);
30
+ const nameLen = buf.readUInt16LE(p + 28), extraLen = buf.readUInt16LE(p + 30), commentLen = buf.readUInt16LE(p + 32);
31
+ const local = buf.readUInt32LE(p + 42);
32
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
33
+ p += 46 + nameLen + extraLen + commentLen;
34
+ files.set(name, () => {
35
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
36
+ const data = buf.subarray(start, start + size);
37
+ if (method === 0) return data;
38
+ if (method === 8) return inflateRawSync(data);
39
+ throw new Error(`unsupported compression in .xlsx entry ${name}`);
40
+ });
41
+ }
42
+ return { file: (name) => files.get(name)?.() ?? null };
43
+ }
44
+
45
+ let _parser;
46
+ function parseXml(buf) {
47
+ _parser ??= new (new JSDOM('').window.DOMParser)();
48
+ return _parser.parseFromString(buf.toString('utf8'), 'application/xml');
49
+ }
50
+
51
+ // "A1" / "BC12" → 0-based column. Column letters are base-26 (bijective).
52
+ function refCol(ref) {
53
+ let col = 0;
54
+ for (let i = 0; i < ref.length; i++) {
55
+ const ch = ref.charCodeAt(i);
56
+ if (ch < 65 || ch > 90) break;
57
+ col = col * 26 + (ch - 64);
58
+ }
59
+ return col - 1;
60
+ }
61
+
62
+ // Text of a shared-string <si> or inline <is> (rich-text runs concatenated).
63
+ function textOf(elem) {
64
+ if (!elem) return '';
65
+ let out = '';
66
+ for (const t of elem.getElementsByTagName('t')) out += t.textContent;
67
+ return out;
68
+ }
69
+
70
+ function firstSheetPath(zip) {
71
+ const wb = zip.file('xl/workbook.xml');
72
+ if (!wb) throw new Error('not an .xlsx workbook (xl/workbook.xml missing)');
73
+ const sheets = parseXml(wb).getElementsByTagName('sheet');
74
+ if (!sheets.length) throw new Error('the workbook has no sheets');
75
+ const rid = sheets[0].getAttribute('r:id')
76
+ || sheets[0].getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id');
77
+ const rels = zip.file('xl/_rels/workbook.xml.rels');
78
+ if (rels) {
79
+ for (const rel of parseXml(rels).getElementsByTagName('Relationship')) {
80
+ if (rel.getAttribute('Id') !== rid) continue;
81
+ const target = rel.getAttribute('Target');
82
+ if (target.charAt(0) === '/') return target.slice(1);
83
+ return target.indexOf('xl/') === 0 ? target : 'xl/' + target;
84
+ }
85
+ }
86
+ return 'xl/worksheets/sheet1.xml';
87
+ }
88
+
89
+ function csvField(v) {
90
+ if (v == null) return '';
91
+ const s = String(v);
92
+ return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
93
+ }
94
+
95
+ function cellValue(cell, shared) {
96
+ const t = cell.getAttribute('t'); // s | inlineStr | str | b | e | n | (none)
97
+ if (t === 'inlineStr') return textOf(cell.getElementsByTagName('is')[0]);
98
+ const v = cell.getElementsByTagName('v')[0];
99
+ const raw = v ? v.textContent : '';
100
+ if (t === 's') return shared[parseInt(raw, 10)] || '';
101
+ if (t === 'b') return raw === '1' ? 'TRUE' : 'FALSE';
102
+ return raw;
103
+ }
104
+
105
+ /** CSV text of the first worksheet of the .xlsx in buf. */
106
+ export function xlsxToCSV(buf) {
107
+ const zip = unzip(buf);
108
+ const sst = zip.file('xl/sharedStrings.xml');
109
+ const shared = sst ? Array.from(parseXml(sst).getElementsByTagName('si'), textOf) : [];
110
+ const path = firstSheetPath(zip);
111
+ const sheet = zip.file(path);
112
+ if (!sheet) throw new Error(`worksheet not found: ${path}`);
113
+
114
+ const grid = [];
115
+ let maxCol = -1;
116
+ for (const rowEl of parseXml(sheet).getElementsByTagName('row')) {
117
+ const row = [];
118
+ Array.from(rowEl.getElementsByTagName('c')).forEach((cell, c) => {
119
+ const ref = cell.getAttribute('r');
120
+ const col = ref ? refCol(ref) : c;
121
+ row[col] = cellValue(cell, shared);
122
+ if (col > maxCol) maxCol = col;
123
+ });
124
+ grid.push(row);
125
+ }
126
+ if (maxCol < 0) return '';
127
+ return grid.map(row => Array.from({ length: maxCol + 1 }, (_, c) => csvField(row[c])).join(',')).join('\r\n');
128
+ }
129
+
130
+ export const isXlsx = (path) => /\.xlsx$/i.test(String(path ?? ''));