xlsx-for-ai 1.4.3 → 1.5.0
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/README.md +74 -0
- package/SECURITY.md +96 -0
- package/index.js +118 -24
- package/lib/bugReport.js +251 -0
- package/lib/engine.js +65 -0
- package/lib/redactWorkbook.js +183 -0
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -293,10 +293,84 @@ curl -o .cursor/rules/read-xlsx.mdc https://raw.githubusercontent.com/senoff/xls
|
|
|
293
293
|
|
|
294
294
|
The same rule works for Claude Code (`.claude/rules/`), Copilot (`.github/copilot-instructions.md`), or any other agent — just adjust the path.
|
|
295
295
|
|
|
296
|
+
## Embedding xlsx-for-ai as a library dependency
|
|
297
|
+
|
|
298
|
+
The CLI install (`npm install -g xlsx-for-ai`) is clean — no deprecation warnings, modern transitive deps via npm `overrides`. If you embed xlsx-for-ai as a library dependency in another project, the picture is slightly different.
|
|
299
|
+
|
|
300
|
+
**Why:** npm's `overrides` field only takes effect when xlsx-for-ai is the top-level project. When xlsx-for-ai is installed as a *transitive* dependency in another project, npm uses the original ExcelJS dep tree (unmodified), and you'll see the upstream ExcelJS deprecation warnings on install. The warnings come from ExcelJS's stale transitive deps (`glob@7`, `rimraf@2`, `lodash.isequal`, `fstream`, `inflight`) and are upstream noise — they don't affect functionality.
|
|
301
|
+
|
|
302
|
+
**To get clean output in a project that depends on xlsx-for-ai**, copy the same overrides into your own `package.json`:
|
|
303
|
+
|
|
304
|
+
```json
|
|
305
|
+
{
|
|
306
|
+
"overrides": {
|
|
307
|
+
"glob": "^13.0.0",
|
|
308
|
+
"rimraf": "^5.0.10",
|
|
309
|
+
"unzipper": "^0.12.3",
|
|
310
|
+
"fast-csv": "^5.0.2"
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Run `rm -rf node_modules package-lock.json && npm install` and the warnings will clear. xlsx-for-ai's tests pass against these versions, so the upgrade is safe.
|
|
316
|
+
|
|
317
|
+
A future release may apply these dep upgrades via `patch-package` so they travel through the dep graph automatically. The infrastructure is in place; the patches haven't been needed urgently because most installs are CLI-direct.
|
|
318
|
+
|
|
319
|
+
## Reporting bugs
|
|
320
|
+
|
|
321
|
+
**The privacy contract: we never auto-send your data.** xlsx-for-ai has no telemetry endpoint and no consent dialog to maintain — there's nothing to opt out of, because nothing leaves your machine unless you choose to attach it to a GitHub issue.
|
|
322
|
+
|
|
323
|
+
When something breaks on a real workbook, two flags help us reproduce locally without asking you to share the original file:
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
# Required — small JSON describing the workbook's structure (no cell content)
|
|
327
|
+
npx xlsx-for-ai --report-bug your-file.xlsx
|
|
328
|
+
|
|
329
|
+
# Optional — full workbook with every cell value replaced by a typed placeholder
|
|
330
|
+
npx xlsx-for-ai --export-redacted-workbook your-file.xlsx
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### `--report-bug`
|
|
334
|
+
|
|
335
|
+
Writes `xlsx-for-ai-bugreport-<ISO-timestamp>.json` to the current directory. The report contains:
|
|
336
|
+
|
|
337
|
+
- File size, sheet count, per-sheet shape (rows × cols), per-sheet merge counts
|
|
338
|
+
- Feature inventory detected via OOXML part inspection — pivot tables, charts, threaded comments, sensitivity labels, linked data types, sparklines, Power Query, slicers, timelines, dynamic arrays, conditional formatting, VBA, and more
|
|
339
|
+
- Defined-name *labels* (e.g. `Totals`) — but NOT their target ranges or formulas
|
|
340
|
+
- Tool version, Node version, OS + arch
|
|
341
|
+
|
|
342
|
+
What the report **never** contains: cell values, formulas, shared strings, named-range targets, comment text, or your absolute file path. You can `cat` it before attaching to verify.
|
|
343
|
+
|
|
344
|
+
### `--export-redacted-workbook`
|
|
345
|
+
|
|
346
|
+
Writes `<input>-redacted.xlsx` next to the input. Every cell value is replaced by a typed placeholder:
|
|
347
|
+
|
|
348
|
+
| Original cell type | Placeholder |
|
|
349
|
+
|--------------------|-------------|
|
|
350
|
+
| Number | `0` |
|
|
351
|
+
| String | `"x"` |
|
|
352
|
+
| Boolean | `false` |
|
|
353
|
+
| ISO date | `1899-12-30`|
|
|
354
|
+
| Error | preserved |
|
|
355
|
+
|
|
356
|
+
Formulas, sheet names, merges, named ranges (formulas), styles, conditional formatting, pivots, charts, queries, and macros are passed through byte-for-byte at the ZIP/XML level (no lossy ExcelJS round-trip). Shared strings and comment payloads are also rewritten to `"x"` for defense-in-depth. Open the redacted file in Excel to confirm it still triggers the bug, then attach it.
|
|
357
|
+
|
|
358
|
+
### Filing the issue
|
|
359
|
+
|
|
360
|
+
Open https://github.com/senoff/xlsx-for-ai/issues — the bug template asks you to drag-drop the JSON (and optionally the redacted workbook). That's the whole workflow. No accounts to create, no SDK to integrate, no consent screen to click through.
|
|
361
|
+
|
|
296
362
|
## Why This Exists
|
|
297
363
|
|
|
298
364
|
Spreadsheets are everywhere in real projects — financial models, data exports, config files, tax estimates. AI coding agents choke on binary formats. This tool makes spreadsheets legible to AI with zero information loss, including the tricky bits like shared formulas, named ranges, and merged cells that other tools drop.
|
|
299
365
|
|
|
366
|
+
## Security
|
|
367
|
+
|
|
368
|
+
`xlsx-for-ai` parses untrusted `.xlsx` files on your machine. The
|
|
369
|
+
project's security policy, supported-versions table, and reporting inbox
|
|
370
|
+
are in [SECURITY.md](SECURITY.md). The supply-chain hardening that goes
|
|
371
|
+
with it lives in [docs/INTEGRITY_PINNING.md](docs/INTEGRITY_PINNING.md)
|
|
372
|
+
and [FORK_READINESS.md](FORK_READINESS.md).
|
|
373
|
+
|
|
300
374
|
## License
|
|
301
375
|
|
|
302
376
|
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
`xlsx-for-ai` is a developer CLI that parses untrusted `.xlsx` files on
|
|
4
|
+
end users' machines and emits text or JSON for AI coding agents. The
|
|
5
|
+
project's security posture is documented across three files; this one is
|
|
6
|
+
the entry point.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
Please do **not** open a public GitHub issue for security reports.
|
|
11
|
+
|
|
12
|
+
Email the maintainer at `bobsenoff@gmail.com` with:
|
|
13
|
+
|
|
14
|
+
- a description of the issue and its impact;
|
|
15
|
+
- a minimal reproducer (a workbook, command, or version pinning is ideal);
|
|
16
|
+
- whether you intend to disclose, and on what timeline.
|
|
17
|
+
|
|
18
|
+
You should expect an acknowledgement within 72 hours. If you do not hear
|
|
19
|
+
back, follow up — the inbox occasionally eats things.
|
|
20
|
+
|
|
21
|
+
This project has no embargo program and no CVE-issuing budget. Coordinate
|
|
22
|
+
disclosure expectations in your first message.
|
|
23
|
+
|
|
24
|
+
## Supported versions
|
|
25
|
+
|
|
26
|
+
The latest published `1.x` minor on npm receives security fixes. Older
|
|
27
|
+
minors do not. Today that is `1.4.x`. If a fix requires a breaking change,
|
|
28
|
+
it is shipped as a `2.x` and the prior minor is deprecated on npm.
|
|
29
|
+
|
|
30
|
+
| Version | Status | Security fixes |
|
|
31
|
+
|---------|-------------|----------------|
|
|
32
|
+
| 1.4.x | current | yes |
|
|
33
|
+
| 1.3.x | superseded | no |
|
|
34
|
+
| ≤ 1.2.x | superseded | no |
|
|
35
|
+
|
|
36
|
+
## What this project considers a security issue
|
|
37
|
+
|
|
38
|
+
In scope:
|
|
39
|
+
|
|
40
|
+
- A maliciously crafted `.xlsx` that causes `xlsx-for-ai` to execute
|
|
41
|
+
arbitrary code, exfiltrate data outside the workbook, write outside the
|
|
42
|
+
current working directory, or hang indefinitely on input that should
|
|
43
|
+
parse or fail in bounded time.
|
|
44
|
+
- A dependency in the production tree (`exceljs` and its parser stack,
|
|
45
|
+
`xlsx`, `papaparse`, `@formulajs/formulajs`, `gpt-tokenizer`) shipping
|
|
46
|
+
a known-bad version through `xlsx-for-ai`'s lockfile.
|
|
47
|
+
- An npm-publish vector — a re-published version of any production dep
|
|
48
|
+
with bytes that differ from the lockfile's pinned integrity hash.
|
|
49
|
+
|
|
50
|
+
Out of scope:
|
|
51
|
+
|
|
52
|
+
- Bugs in the AI agent that *consumes* the output. We dump bytes; we do
|
|
53
|
+
not vouch for what an LLM does with them.
|
|
54
|
+
- Performance issues on legitimate workbooks that happen to be very
|
|
55
|
+
large. File a normal issue.
|
|
56
|
+
- Vulnerabilities in dev-only dependencies that cannot be reached from
|
|
57
|
+
the published package surface (`files` in `package.json` controls
|
|
58
|
+
what ships).
|
|
59
|
+
|
|
60
|
+
## How this is enforced
|
|
61
|
+
|
|
62
|
+
Three documents and two CI workflows do the work:
|
|
63
|
+
|
|
64
|
+
- `docs/INTEGRITY_PINNING.md` — the integrity-pinning contract: lockfile
|
|
65
|
+
is source of truth, `npm ci --ignore-scripts` everywhere in CI, SRI
|
|
66
|
+
hashes verified on every install, signature verification required on
|
|
67
|
+
every dep-touching PR, daily drift sweep, audit allowlist policy.
|
|
68
|
+
- `FORK_READINESS.md` — the runbook for an upstream npm-account
|
|
69
|
+
compromise (specifically, `@protobi/exceljs`, the soft fork we may
|
|
70
|
+
adopt for pivot-table support). Covers triggers, pre-positioning, and
|
|
71
|
+
the freeze/diagnose/decide/fork response.
|
|
72
|
+
- `.github/audit-allowlist.json` — the enumerated set of triaged
|
|
73
|
+
high-or-critical advisories the audit gate intentionally suppresses,
|
|
74
|
+
with rationale and reassess dates. Adding an entry is a security-policy
|
|
75
|
+
change.
|
|
76
|
+
- `.github/workflows/audit.yml` — `npm audit` on every PR + a daily
|
|
77
|
+
cron, gated against the allowlist.
|
|
78
|
+
- `.github/workflows/upgrade-verify.yml` — `npm audit signatures` plus a
|
|
79
|
+
registry re-resolve check on every PR that touches `package.json` or
|
|
80
|
+
`package-lock.json`. Catches the silent-republish vector.
|
|
81
|
+
|
|
82
|
+
If you are reporting a finding, naming which of these failed (or which
|
|
83
|
+
should have caught it) is helpful but not required.
|
|
84
|
+
|
|
85
|
+
## Threat model in one paragraph
|
|
86
|
+
|
|
87
|
+
The high-value attack against `xlsx-for-ai` is supply chain: an attacker
|
|
88
|
+
who compromises the npm publish credentials of `exceljs`, `@protobi/exceljs`,
|
|
89
|
+
or any package in the `exceljs-family` group can ship arbitrary code that
|
|
90
|
+
runs on every `npm install`. The next-highest is a malicious workbook
|
|
91
|
+
that leverages a parser bug in that same stack. We do not try to defend
|
|
92
|
+
against the OS being compromised, nor against the user's AI agent acting
|
|
93
|
+
on the output. Everything in `INTEGRITY_PINNING.md` and `FORK_READINESS.md`
|
|
94
|
+
exists to detect or recover from supply-chain compromise; everything in
|
|
95
|
+
the audit workflows exists to catch parser CVEs the moment they are
|
|
96
|
+
disclosed.
|
package/index.js
CHANGED
|
@@ -21,7 +21,12 @@ if (!process.env.XLSX_FOR_AI_RESPAWNED) {
|
|
|
21
21
|
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const fs = require('fs');
|
|
24
|
-
|
|
24
|
+
// All xlsx-engine access goes through the engine abstraction in lib/engine.js
|
|
25
|
+
// — never require the underlying engine directly. To swap engines (fork,
|
|
26
|
+
// different library, server-side service), replace lib/engine.js. Nothing
|
|
27
|
+
// else changes. Current engine: @protobi/exceljs (drop-in fork of exceljs
|
|
28
|
+
// with active maintenance + preservation patches; see ROADMAP for rationale).
|
|
29
|
+
const engine = require('./lib/engine');
|
|
25
30
|
|
|
26
31
|
// Lazy-load heavy deps only when their feature is used (keeps cold start fast
|
|
27
32
|
// for the common --stdout / --json / --md path that needs none of them).
|
|
@@ -53,6 +58,8 @@ function parseArgs(argv) {
|
|
|
53
58
|
maxRows: null,
|
|
54
59
|
maxCols: null,
|
|
55
60
|
maxTokens: null,
|
|
61
|
+
reportBug: null,
|
|
62
|
+
exportRedactedWorkbook: null,
|
|
56
63
|
help: false,
|
|
57
64
|
};
|
|
58
65
|
let i = 0;
|
|
@@ -73,6 +80,8 @@ function parseArgs(argv) {
|
|
|
73
80
|
else if (arg === '--max-rows') { opts.maxRows = parseInt(argv[++i], 10); }
|
|
74
81
|
else if (arg === '--max-cols') { opts.maxCols = parseInt(argv[++i], 10); }
|
|
75
82
|
else if (arg === '--max-tokens') { opts.maxTokens = parseInt(argv[++i], 10); }
|
|
83
|
+
else if (arg === '--report-bug') { opts.reportBug = argv[++i]; }
|
|
84
|
+
else if (arg === '--export-redacted-workbook'){ opts.exportRedactedWorkbook = argv[++i]; }
|
|
76
85
|
else if (arg === '-h' || arg === '--help') opts.help = true;
|
|
77
86
|
else opts.positional.push(arg);
|
|
78
87
|
i++;
|
|
@@ -119,6 +128,19 @@ Other modes:
|
|
|
119
128
|
--stream Streaming reader for huge .xlsx files (>100MB);
|
|
120
129
|
emits row-by-row, drops some sheet metadata
|
|
121
130
|
|
|
131
|
+
Bug reporting (privacy-by-design — no data leaves your machine):
|
|
132
|
+
--report-bug <input.xlsx>
|
|
133
|
+
Generate xlsx-for-ai-bugreport-<ISO>.json describing
|
|
134
|
+
the workbook's structure (sheet count/shape, feature
|
|
135
|
+
inventory, env). Contains zero cell values, formulas,
|
|
136
|
+
or named-range targets. Attach to a GitHub issue.
|
|
137
|
+
--export-redacted-workbook <input.xlsx>
|
|
138
|
+
Produce <input>-redacted.xlsx with every cell value
|
|
139
|
+
replaced by a typed placeholder (numbers→0,
|
|
140
|
+
strings→"x", bools→false, dates→1900-01-01). Formulas,
|
|
141
|
+
structure, styles, named ranges preserved. Optional
|
|
142
|
+
attachment for hard-to-repro bugs.
|
|
143
|
+
|
|
122
144
|
Misc:
|
|
123
145
|
-h, --help Show this help
|
|
124
146
|
|
|
@@ -353,7 +375,7 @@ function dumpSheet(ws, wb, opts = {}) {
|
|
|
353
375
|
lines.push(`(${ws.columnCount - endCol} more columns truncated)`);
|
|
354
376
|
}
|
|
355
377
|
|
|
356
|
-
const merges =
|
|
378
|
+
const merges = (ws.model && Array.isArray(ws.model.merges)) ? ws.model.merges : [];
|
|
357
379
|
if (merges.length) lines.push(`Merged: ${merges.join(', ')}`);
|
|
358
380
|
|
|
359
381
|
if (ws.autoFilter) {
|
|
@@ -413,7 +435,7 @@ function dumpSheet(ws, wb, opts = {}) {
|
|
|
413
435
|
if (raw == null || raw === '') continue;
|
|
414
436
|
const ref = `${colLetter(c)}${r}`;
|
|
415
437
|
const tags = [];
|
|
416
|
-
if (cell.type ===
|
|
438
|
+
if (cell.type === engine.ValueType.Formula && typeof raw === 'object') {
|
|
417
439
|
if (raw.formula) tags.push(`formula: =${raw.formula}`);
|
|
418
440
|
else if (raw.sharedFormula) tags.push(`shared formula ref: ${raw.sharedFormula}`);
|
|
419
441
|
}
|
|
@@ -480,7 +502,7 @@ function dumpSheetMarkdown(ws, wb, opts = {}) {
|
|
|
480
502
|
meta.push(`Total: ${ws.rowCount} rows × ${ws.columnCount} cols`);
|
|
481
503
|
const frozen = (ws.views || []).find(v => v.state === 'frozen');
|
|
482
504
|
if (frozen) meta.push(`Frozen: row ${frozen.ySplit ?? 0}, col ${frozen.xSplit ?? 0}`);
|
|
483
|
-
const merges =
|
|
505
|
+
const merges = (ws.model && Array.isArray(ws.model.merges)) ? ws.model.merges : [];
|
|
484
506
|
if (merges.length) meta.push(`Merged: ${merges.slice(0, 6).join(', ')}${merges.length > 6 ? ', ...' : ''}`);
|
|
485
507
|
const namedRanges = getNamedRanges(wb, ws.name);
|
|
486
508
|
if (namedRanges.length) meta.push(`Named ranges: ${namedRanges.map(n => n.name).join(', ')}`);
|
|
@@ -581,7 +603,8 @@ function dumpSheetJSON(ws, wb, opts = {}) {
|
|
|
581
603
|
frozen: null,
|
|
582
604
|
columns: [],
|
|
583
605
|
hiddenColumns: [],
|
|
584
|
-
|
|
606
|
+
hiddenRows: [],
|
|
607
|
+
merges: (ws.model && Array.isArray(ws.model.merges)) ? ws.model.merges.slice() : [],
|
|
585
608
|
autoFilter: null,
|
|
586
609
|
printArea: null,
|
|
587
610
|
namedRanges: getNamedRanges(wb, ws.name),
|
|
@@ -652,6 +675,7 @@ function dumpSheetJSON(ws, wb, opts = {}) {
|
|
|
652
675
|
|
|
653
676
|
for (let r = startRow; r <= endRow; r++) {
|
|
654
677
|
const row = ws.getRow(r);
|
|
678
|
+
if (row.hidden) out.hiddenRows.push(r);
|
|
655
679
|
for (let c = startCol; c <= endCol; c++) {
|
|
656
680
|
const cell = row.getCell(c);
|
|
657
681
|
const raw = cell.value;
|
|
@@ -902,12 +926,10 @@ function applyTokenBudget(text, maxTokens) {
|
|
|
902
926
|
async function loadAnyWorkbook(filePath) {
|
|
903
927
|
const ext = path.extname(filePath).toLowerCase();
|
|
904
928
|
if (ext === '.xlsx') {
|
|
905
|
-
|
|
906
|
-
await wb.xlsx.readFile(filePath);
|
|
907
|
-
return wb;
|
|
929
|
+
return engine.loadWorkbook(filePath);
|
|
908
930
|
}
|
|
909
931
|
if (ext === '.csv' || ext === '.tsv') {
|
|
910
|
-
const wb =
|
|
932
|
+
const wb = engine.createWorkbook();
|
|
911
933
|
const ws = wb.addWorksheet(path.basename(filePath, ext));
|
|
912
934
|
const text = fs.readFileSync(filePath, 'utf8');
|
|
913
935
|
const papa = lazyPapa();
|
|
@@ -922,13 +944,13 @@ async function loadAnyWorkbook(filePath) {
|
|
|
922
944
|
throw new Error(`Unsupported extension: ${ext}. Supported: .xlsx .xls .xlsb .ods .csv .tsv`);
|
|
923
945
|
}
|
|
924
946
|
|
|
925
|
-
// Read a non-xlsx spreadsheet via SheetJS, materialize into
|
|
926
|
-
//
|
|
927
|
-
// unchanged. Loses some formatting; preserves values + formulas.
|
|
947
|
+
// Read a non-xlsx spreadsheet via SheetJS, materialize into the engine's
|
|
948
|
+
// workbook representation so the rest of the code (dump/markdown/json/sql/
|
|
949
|
+
// schema) works unchanged. Loses some formatting; preserves values + formulas.
|
|
928
950
|
function loadViaSheetJS(filePath) {
|
|
929
951
|
const XLSX = lazyXlsx();
|
|
930
952
|
const sheetJsWb = XLSX.readFile(filePath, { cellFormula: true, cellDates: true });
|
|
931
|
-
const wb =
|
|
953
|
+
const wb = engine.createWorkbook();
|
|
932
954
|
for (const name of sheetJsWb.SheetNames) {
|
|
933
955
|
const sjsSheet = sheetJsWb.Sheets[name];
|
|
934
956
|
const ws = wb.addWorksheet(name);
|
|
@@ -959,7 +981,7 @@ function loadViaSheetJS(filePath) {
|
|
|
959
981
|
// ---------------------------------------------------------------------------
|
|
960
982
|
|
|
961
983
|
async function streamDump(filePath, opts) {
|
|
962
|
-
const wb =
|
|
984
|
+
const wb = engine.streamReader(filePath, {
|
|
963
985
|
sharedStrings: 'cache',
|
|
964
986
|
hyperlinks: 'ignore',
|
|
965
987
|
worksheets: 'emit',
|
|
@@ -1287,7 +1309,7 @@ function applyNumberFormat(ws, ref, fmt) {
|
|
|
1287
1309
|
}
|
|
1288
1310
|
|
|
1289
1311
|
function buildWorkbook(spec) {
|
|
1290
|
-
const wb =
|
|
1312
|
+
const wb = engine.createWorkbook();
|
|
1291
1313
|
const warnings = []; // [{type, sheet, ref}, ...]
|
|
1292
1314
|
|
|
1293
1315
|
function track(sheetName, ref, lossy) {
|
|
@@ -1382,6 +1404,17 @@ function buildWorkbook(spec) {
|
|
|
1382
1404
|
}
|
|
1383
1405
|
}
|
|
1384
1406
|
|
|
1407
|
+
// Restore hidden-row state from --json round-trip. Without this, the
|
|
1408
|
+
// `hiddenRows: [...]` field emitted on read is silently dropped on write,
|
|
1409
|
+
// breaking the round-trip claim for fixtures like annotations.xlsx.
|
|
1410
|
+
if (Array.isArray(sheet.hiddenRows)) {
|
|
1411
|
+
for (const n of sheet.hiddenRows) {
|
|
1412
|
+
if (typeof n === 'number' && n >= 1) {
|
|
1413
|
+
try { ws.getRow(n).hidden = true; } catch (_) {}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1385
1418
|
if (Array.isArray(sheet.merges)) {
|
|
1386
1419
|
for (const m of sheet.merges) {
|
|
1387
1420
|
try { ws.mergeCells(m); } catch (_) {}
|
|
@@ -1644,7 +1677,7 @@ async function mainWrite(argv) {
|
|
|
1644
1677
|
outPath = path.resolve(outPath);
|
|
1645
1678
|
|
|
1646
1679
|
try {
|
|
1647
|
-
await
|
|
1680
|
+
await engine.writeWorkbook(wb, outPath);
|
|
1648
1681
|
} catch (e) {
|
|
1649
1682
|
console.error(`Write error: ${e.message}`);
|
|
1650
1683
|
process.exit(1);
|
|
@@ -1675,6 +1708,28 @@ async function main() {
|
|
|
1675
1708
|
const opts = parseArgs(argv);
|
|
1676
1709
|
|
|
1677
1710
|
if (opts.help) { printHelp(); process.exit(0); }
|
|
1711
|
+
|
|
1712
|
+
// Bug-report and redacted-workbook modes consume their input via the
|
|
1713
|
+
// flag itself, so they bypass the normal positional / loader path.
|
|
1714
|
+
if (opts.reportBug) {
|
|
1715
|
+
const { generateBugReport, writeBugReport } = require('./lib/bugReport');
|
|
1716
|
+
const inputPath = path.resolve(opts.reportBug);
|
|
1717
|
+
const report = await generateBugReport(inputPath);
|
|
1718
|
+
const outPath = writeBugReport(report, process.cwd());
|
|
1719
|
+
console.log(outPath);
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (opts.exportRedactedWorkbook) {
|
|
1723
|
+
const { exportRedactedWorkbook } = require('./lib/redactWorkbook');
|
|
1724
|
+
const inputPath = path.resolve(opts.exportRedactedWorkbook);
|
|
1725
|
+
const ext = path.extname(inputPath);
|
|
1726
|
+
const base = path.basename(inputPath, ext);
|
|
1727
|
+
const outPath = path.join(path.dirname(inputPath), `${base}-redacted${ext}`);
|
|
1728
|
+
await exportRedactedWorkbook(inputPath, outPath);
|
|
1729
|
+
console.log(outPath);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1678
1733
|
if (opts.positional.length < 1) { printHelp(); process.exit(1); }
|
|
1679
1734
|
|
|
1680
1735
|
const filePath = path.resolve(opts.positional[0]);
|
|
@@ -1864,11 +1919,50 @@ async function main() {
|
|
|
1864
1919
|
}
|
|
1865
1920
|
}
|
|
1866
1921
|
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1922
|
+
// Run as CLI when invoked directly. Skip when imported so tests can require
|
|
1923
|
+
// this module and exercise its internals without triggering main().
|
|
1924
|
+
if (require.main === module) {
|
|
1925
|
+
main().catch((err) => {
|
|
1926
|
+
const msg = err && err.message ? err.message : String(err);
|
|
1927
|
+
console.error(msg);
|
|
1928
|
+
if (/Invalid string length/i.test(msg)) {
|
|
1929
|
+
console.error('Hint: this sheet renders to a text dump larger than V8\'s 512MB string limit. Try --max-rows N, --max-cols N, --max-tokens N, --range A1:..., or --stream.');
|
|
1930
|
+
}
|
|
1931
|
+
process.exit(1);
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// Export internals for unit tests. Production CLI use never touches these
|
|
1936
|
+
// exports — this is only for `require('./index.js')` in test files.
|
|
1937
|
+
module.exports = {
|
|
1938
|
+
// arg parsing
|
|
1939
|
+
parseArgs,
|
|
1940
|
+
parseWriteArgs,
|
|
1941
|
+
// pure utilities
|
|
1942
|
+
colLetter,
|
|
1943
|
+
colNum,
|
|
1944
|
+
parseRange,
|
|
1945
|
+
isDefaultTextColor,
|
|
1946
|
+
describeFill,
|
|
1947
|
+
describeFont,
|
|
1948
|
+
formatValue,
|
|
1949
|
+
plainValue,
|
|
1950
|
+
jsonValue,
|
|
1951
|
+
describeNote,
|
|
1952
|
+
escapeMd,
|
|
1953
|
+
coerceMaybeDate,
|
|
1954
|
+
coerceMarkdownValue,
|
|
1955
|
+
// schema/format
|
|
1956
|
+
inferType,
|
|
1957
|
+
sqlIdent,
|
|
1958
|
+
sqlVal,
|
|
1959
|
+
// spec parsing
|
|
1960
|
+
parseMarkdownSpec,
|
|
1961
|
+
validateSpec,
|
|
1962
|
+
buildCellValue,
|
|
1963
|
+
// workbook builders
|
|
1964
|
+
buildWorkbook,
|
|
1965
|
+
trySimpleEval,
|
|
1966
|
+
// budget
|
|
1967
|
+
applyTokenBudget,
|
|
1968
|
+
};
|
package/lib/bugReport.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Bug-report generator for xlsx-for-ai.
|
|
2
|
+
//
|
|
3
|
+
// Produces a JSON blob describing the *structure* of an .xlsx workbook
|
|
4
|
+
// (sheet count + shape, used-features inventory, env) with ZERO user
|
|
5
|
+
// content (no cell values, no formulas, no shared strings, no
|
|
6
|
+
// named-range formulas, no comment text). Designed to be safe for a
|
|
7
|
+
// reporter to attach to a public GitHub issue.
|
|
8
|
+
//
|
|
9
|
+
// Implementation:
|
|
10
|
+
// 1. Read the .xlsx as a ZIP via JSZip (already a transitive dep
|
|
11
|
+
// of exceljs). Walk the OOXML parts to detect features by
|
|
12
|
+
// filename pattern + targeted ContentType / relationship lookups.
|
|
13
|
+
// 2. Use ExcelJS only for sheet shape (rowCount, columnCount),
|
|
14
|
+
// merge counts, and named-range *names* (not their refs/formulas).
|
|
15
|
+
//
|
|
16
|
+
// We deliberately avoid emitting anything sourced from cell text,
|
|
17
|
+
// shared strings, or formula expressions. The bug-report consumer
|
|
18
|
+
// should be able to grep the output for any user content and find none.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const JSZip = require('jszip');
|
|
24
|
+
const ExcelJS = require('@protobi/exceljs');
|
|
25
|
+
|
|
26
|
+
const PKG_VERSION = require('../package.json').version;
|
|
27
|
+
|
|
28
|
+
// OOXML feature detectors. Each entry maps a feature key to a predicate
|
|
29
|
+
// over the list of zip entry filenames. We choose names + content-type
|
|
30
|
+
// matches that are stable across Excel versions.
|
|
31
|
+
//
|
|
32
|
+
// References:
|
|
33
|
+
// ECMA-376 part-1 (OOXML) section 18.x for sheet parts
|
|
34
|
+
// MS-OE376 for vendor extension parts
|
|
35
|
+
const FEATURE_PATTERNS = [
|
|
36
|
+
// Pivot tables: xl/pivotTables/pivotTable*.xml + xl/pivotCache/*
|
|
37
|
+
{ key: 'pivotTables', test: (n) => /^xl\/pivotTables\/pivotTable\d+\.xml$/i.test(n) },
|
|
38
|
+
{ key: 'pivotCaches', test: (n) => /^xl\/pivotCache\/pivotCacheDefinition\d+\.xml$/i.test(n) },
|
|
39
|
+
|
|
40
|
+
// Charts (drawing-based + chartsheets)
|
|
41
|
+
{ key: 'charts', test: (n) => /^xl\/charts\/chart\d+\.xml$/i.test(n) },
|
|
42
|
+
{ key: 'chartsheets', test: (n) => /^xl\/chartsheets\/sheet\d+\.xml$/i.test(n) },
|
|
43
|
+
{ key: 'drawings', test: (n) => /^xl\/drawings\/drawing\d+\.xml$/i.test(n) },
|
|
44
|
+
|
|
45
|
+
// Threaded comments (modern; Office 365). Plain comments are detected separately.
|
|
46
|
+
{ key: 'threadedComments', test: (n) => /^xl\/threadedComments\/threadedComment\d+\.xml$/i.test(n) },
|
|
47
|
+
{ key: 'comments', test: (n) => /^xl\/comments\d+\.xml$/i.test(n) },
|
|
48
|
+
{ key: 'persons', test: (n) => /^xl\/persons\/person\.xml$/i.test(n) },
|
|
49
|
+
|
|
50
|
+
// Sensitivity labels (MIP). docMetadata folder + LabelInfo part.
|
|
51
|
+
{ key: 'sensitivityLabel', test: (n) => /^docMetadata\/LabelInfo\.xml$/i.test(n) },
|
|
52
|
+
|
|
53
|
+
// Linked / rich data types (the "Stocks", "Geography" data types).
|
|
54
|
+
{ key: 'richValueData', test: (n) => /^xl\/richData\/rdRichValues\.xml$/i.test(n) },
|
|
55
|
+
{ key: 'richValueRel', test: (n) => /^xl\/richData\/richValueRel\.xml$/i.test(n) },
|
|
56
|
+
|
|
57
|
+
// Power Query / Data Model
|
|
58
|
+
{ key: 'powerQuery', test: (n) => /^xl\/queryTables\/queryTable\d+\.xml$/i.test(n)
|
|
59
|
+
|| /^customXml\/item\d+\.xml$/i.test(n) && false /* refined below */ },
|
|
60
|
+
{ key: 'dataModel', test: (n) => /^xl\/model\/item\.data$/i.test(n) },
|
|
61
|
+
{ key: 'connections', test: (n) => /^xl\/connections\.xml$/i.test(n) },
|
|
62
|
+
|
|
63
|
+
// Slicers / Timelines (modern PivotTable controls)
|
|
64
|
+
{ key: 'slicers', test: (n) => /^xl\/slicers\/slicer\d+\.xml$/i.test(n) },
|
|
65
|
+
{ key: 'slicerCaches', test: (n) => /^xl\/slicerCaches\/slicerCache\d+\.xml$/i.test(n) },
|
|
66
|
+
{ key: 'timelines', test: (n) => /^xl\/timelines\/timeline\d+\.xml$/i.test(n) },
|
|
67
|
+
{ key: 'timelineCaches', test: (n) => /^xl\/timelineCaches\/timelineCache\d+\.xml$/i.test(n) },
|
|
68
|
+
|
|
69
|
+
// Tables (Excel ListObjects)
|
|
70
|
+
{ key: 'tables', test: (n) => /^xl\/tables\/table\d+\.xml$/i.test(n) },
|
|
71
|
+
|
|
72
|
+
// External links / workbook references
|
|
73
|
+
{ key: 'externalLinks', test: (n) => /^xl\/externalLinks\/externalLink\d+\.xml$/i.test(n) },
|
|
74
|
+
|
|
75
|
+
// Macros / VBA
|
|
76
|
+
{ key: 'vbaProject', test: (n) => /^xl\/vbaProject\.bin$/i.test(n) },
|
|
77
|
+
|
|
78
|
+
// Custom XML parts (often used by enterprise add-ins / SharePoint)
|
|
79
|
+
{ key: 'customXml', test: (n) => /^customXml\/item\d+\.xml$/i.test(n) },
|
|
80
|
+
|
|
81
|
+
// Embedded objects (OLE)
|
|
82
|
+
{ key: 'embeddings', test: (n) => /^xl\/embeddings\/.+/i.test(n) },
|
|
83
|
+
|
|
84
|
+
// Theme + custom properties (low signal but cheap)
|
|
85
|
+
{ key: 'customProps', test: (n) => /^docProps\/custom\.xml$/i.test(n) },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// Detect dynamic arrays + sparklines from sheet XML. These don't have
|
|
89
|
+
// dedicated parts — they're attributes on cell / extLst inside sheetN.xml.
|
|
90
|
+
// We do a coarse string scan (no value extraction) just to flag presence.
|
|
91
|
+
//
|
|
92
|
+
// Dynamic arrays: <f t="array" ...> with <ext> CT_ExtensionList for
|
|
93
|
+
// x14ac:cm, or modern: presence of <ext> with namespace x17 + cm attr.
|
|
94
|
+
// Sparklines: <ext><x14:sparklineGroups> inside <extLst>.
|
|
95
|
+
async function detectInSheetFeatures(zip, sheetNames) {
|
|
96
|
+
const flags = { dynamicArrays: false, sparklines: false, conditionalFormatting: false };
|
|
97
|
+
for (const name of sheetNames) {
|
|
98
|
+
const file = zip.file(name);
|
|
99
|
+
if (!file) continue;
|
|
100
|
+
const xml = await file.async('string');
|
|
101
|
+
// Coarse but conservative — we look for tag names only, never values.
|
|
102
|
+
if (!flags.dynamicArrays && /\bcm="\d+"/.test(xml)) flags.dynamicArrays = true;
|
|
103
|
+
if (!flags.dynamicArrays && /<f[^>]*\bt="array"/.test(xml)) flags.dynamicArrays = true;
|
|
104
|
+
if (!flags.sparklines && /sparklineGroup/.test(xml)) flags.sparklines = true;
|
|
105
|
+
if (!flags.conditionalFormatting && /<conditionalFormatting/.test(xml))
|
|
106
|
+
flags.conditionalFormatting = true;
|
|
107
|
+
}
|
|
108
|
+
return flags;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inventoryFeatures(filenames) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const { key, test } of FEATURE_PATTERNS) {
|
|
114
|
+
const count = filenames.filter(test).length;
|
|
115
|
+
if (count > 0) out[key] = count;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Given the workbook.xml, extract the sheet relationship Ids and order
|
|
121
|
+
// without reading any user content. We just need names and rIds so we
|
|
122
|
+
// can pair them with worksheet parts to compute per-sheet stats.
|
|
123
|
+
function listSheetPartNames(zip) {
|
|
124
|
+
// Resolve via workbook rels: xl/_rels/workbook.xml.rels.
|
|
125
|
+
const out = [];
|
|
126
|
+
const relsFile = zip.file('xl/_rels/workbook.xml.rels');
|
|
127
|
+
if (!relsFile) return out;
|
|
128
|
+
// Sync — we already have the file in memory inside JSZip.
|
|
129
|
+
// We use a lightweight regex; structural only, no values inside.
|
|
130
|
+
// Each Relationship: <Relationship Id="rId1" Type="..." Target="worksheets/sheet1.xml"/>
|
|
131
|
+
// We can't do sync read without loading; caller already loaded.
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function generateBugReport(filePath) {
|
|
136
|
+
if (!fs.existsSync(filePath)) {
|
|
137
|
+
throw new Error(`File not found: ${filePath}`);
|
|
138
|
+
}
|
|
139
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
140
|
+
if (ext !== '.xlsx' && ext !== '.xlsm') {
|
|
141
|
+
throw new Error(`--report-bug only supports .xlsx / .xlsm (got ${ext})`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const stat = fs.statSync(filePath);
|
|
145
|
+
const buf = fs.readFileSync(filePath);
|
|
146
|
+
const zip = await JSZip.loadAsync(buf);
|
|
147
|
+
const filenames = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
|
|
148
|
+
|
|
149
|
+
const features = inventoryFeatures(filenames);
|
|
150
|
+
|
|
151
|
+
// Sheet parts list — derived from filename pattern, not content.
|
|
152
|
+
const sheetParts = filenames.filter((n) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(n));
|
|
153
|
+
|
|
154
|
+
// In-sheet feature flags (string scan, no extraction).
|
|
155
|
+
const inSheet = await detectInSheetFeatures(zip, sheetParts);
|
|
156
|
+
if (inSheet.dynamicArrays) features.dynamicArrays = true;
|
|
157
|
+
if (inSheet.sparklines) features.sparklines = true;
|
|
158
|
+
if (inSheet.conditionalFormatting) features.conditionalFormatting = true;
|
|
159
|
+
|
|
160
|
+
// Use ExcelJS for sheet shape, merges, and *names* of named ranges.
|
|
161
|
+
// We never read cell values or named-range formulas — only enumerate.
|
|
162
|
+
let sheetCount = 0;
|
|
163
|
+
let mergedTotal = 0;
|
|
164
|
+
let namedRangesCount = 0;
|
|
165
|
+
let definedNames = [];
|
|
166
|
+
const perSheet = [];
|
|
167
|
+
let exceljsError = null;
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const wb = new ExcelJS.Workbook();
|
|
171
|
+
await wb.xlsx.readFile(filePath);
|
|
172
|
+
sheetCount = wb.worksheets.length;
|
|
173
|
+
for (const ws of wb.worksheets) {
|
|
174
|
+
const merges = ws.model && ws.model.merges ? ws.model.merges.length : 0;
|
|
175
|
+
mergedTotal += merges;
|
|
176
|
+
perSheet.push({
|
|
177
|
+
index: ws.id,
|
|
178
|
+
rows: ws.rowCount || 0,
|
|
179
|
+
cols: ws.columnCount || 0,
|
|
180
|
+
merges,
|
|
181
|
+
hidden: ws.state && ws.state !== 'visible' ? ws.state : null,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// Defined names — names ONLY (deliberately drop ranges/formulas).
|
|
185
|
+
const dnModel = wb.definedNames && wb.definedNames.model;
|
|
186
|
+
if (Array.isArray(dnModel)) {
|
|
187
|
+
namedRangesCount = dnModel.length;
|
|
188
|
+
definedNames = dnModel
|
|
189
|
+
.map((d) => (d && typeof d.name === 'string' ? d.name : null))
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// ExcelJS may fail on edge-case files; report the error class but
|
|
194
|
+
// don't include the message verbatim (could leak a path inside the
|
|
195
|
+
// workbook). Sheet count falls back to part count.
|
|
196
|
+
exceljsError = err && err.name ? err.name : 'Error';
|
|
197
|
+
sheetCount = sheetParts.length;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const report = {
|
|
201
|
+
schema: 'xlsx-for-ai/bug-report/v1',
|
|
202
|
+
generatedAt: new Date().toISOString(),
|
|
203
|
+
tool: {
|
|
204
|
+
name: 'xlsx-for-ai',
|
|
205
|
+
version: PKG_VERSION,
|
|
206
|
+
},
|
|
207
|
+
runtime: {
|
|
208
|
+
node: process.version,
|
|
209
|
+
platform: process.platform, // e.g. 'darwin', 'linux', 'win32'
|
|
210
|
+
arch: process.arch, // e.g. 'arm64', 'x64'
|
|
211
|
+
osRelease: os.release(),
|
|
212
|
+
},
|
|
213
|
+
file: {
|
|
214
|
+
// ONLY the basename + size — never the absolute path (could leak
|
|
215
|
+
// user/dir names). The reporter knows what file they ran it on.
|
|
216
|
+
basename: path.basename(filePath),
|
|
217
|
+
ext,
|
|
218
|
+
sizeBytes: stat.size,
|
|
219
|
+
},
|
|
220
|
+
workbook: {
|
|
221
|
+
sheetCount,
|
|
222
|
+
mergedRangeCountTotal: mergedTotal,
|
|
223
|
+
namedRangesCount,
|
|
224
|
+
// Names only — Excel defined-name *names* are user-chosen labels
|
|
225
|
+
// ("Totals", "TaxRate"). We emit them because they're often the
|
|
226
|
+
// hint a maintainer needs. If a reporter considers their names
|
|
227
|
+
// sensitive, they should sanitize before attaching.
|
|
228
|
+
definedNames,
|
|
229
|
+
perSheet,
|
|
230
|
+
featuresPresent: features,
|
|
231
|
+
},
|
|
232
|
+
notes: [
|
|
233
|
+
'This report contains zero cell values, formulas, shared strings, named-range formulas, or comment text.',
|
|
234
|
+
'Defined-name *labels* are included (e.g. "Totals") but their target ranges are not.',
|
|
235
|
+
'Generated with --report-bug. Attach to a GitHub issue at https://github.com/senoff/xlsx-for-ai/issues',
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
if (exceljsError) {
|
|
239
|
+
report.workbook.exceljsLoadError = exceljsError;
|
|
240
|
+
}
|
|
241
|
+
return report;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function writeBugReport(report, cwd) {
|
|
245
|
+
const ts = report.generatedAt.replace(/[:.]/g, '-');
|
|
246
|
+
const outPath = path.join(cwd, `xlsx-for-ai-bugreport-${ts}.json`);
|
|
247
|
+
fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf8');
|
|
248
|
+
return outPath;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { generateBugReport, writeBugReport };
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Engine abstraction layer.
|
|
2
|
+
//
|
|
3
|
+
// xlsx-for-ai's logic shouldn't depend directly on ExcelJS. This module is
|
|
4
|
+
// the *seam* between xlsx-for-ai's code and the underlying xlsx engine —
|
|
5
|
+
// today ExcelJS, tomorrow possibly a fork, a from-scratch JS port,
|
|
6
|
+
// xlsx-populate, or SheetJS Pro server-side.
|
|
7
|
+
//
|
|
8
|
+
// The exposed surface is intentionally narrow: file I/O entry points
|
|
9
|
+
// (load, stream, write), workbook construction, and the small set of
|
|
10
|
+
// ExcelJS constants the rest of the codebase uses. The in-memory workbook
|
|
11
|
+
// representation flows through this layer unchanged — at this stage the
|
|
12
|
+
// goal is to centralize *which engine produces the workbook objects*, not
|
|
13
|
+
// to define a fully-engine-agnostic in-memory model.
|
|
14
|
+
//
|
|
15
|
+
// To swap engines, replace this file. xlsx-for-ai's other modules import
|
|
16
|
+
// only from here; nothing else has a direct require('@protobi/exceljs').
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const ExcelJS = require('@protobi/exceljs');
|
|
21
|
+
|
|
22
|
+
class ExcelJSEngine {
|
|
23
|
+
/** Engine identifier — useful for diagnostics. */
|
|
24
|
+
get name() { return 'exceljs'; }
|
|
25
|
+
get version() {
|
|
26
|
+
try { return require('exceljs/package.json').version; } catch (_) { return 'unknown'; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load a workbook from a file path. Returns the engine's workbook object
|
|
31
|
+
* (currently an ExcelJS Workbook).
|
|
32
|
+
*/
|
|
33
|
+
async loadWorkbook(filePath) {
|
|
34
|
+
const wb = new ExcelJS.Workbook();
|
|
35
|
+
await wb.xlsx.readFile(filePath);
|
|
36
|
+
return wb;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Construct an empty workbook (used by write mode and CSV/TSV/legacy load paths). */
|
|
40
|
+
createWorkbook() {
|
|
41
|
+
return new ExcelJS.Workbook();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write a workbook to disk. */
|
|
45
|
+
async writeWorkbook(wb, filePath) {
|
|
46
|
+
return wb.xlsx.writeFile(filePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Streaming reader for huge files. Returns an async iterator of sheets. */
|
|
50
|
+
streamReader(filePath, opts) {
|
|
51
|
+
return new ExcelJS.stream.xlsx.WorkbookReader(filePath, opts);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Constants the rest of the codebase needs. Keeping these here means
|
|
56
|
+
* the rest of xlsx-for-ai never imports ExcelJS directly — only from
|
|
57
|
+
* the engine.
|
|
58
|
+
*/
|
|
59
|
+
get ValueType() { return ExcelJS.ValueType; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Singleton: the rest of the codebase imports this module and gets the
|
|
63
|
+
// active engine. To swap engines, replace `module.exports` with a different
|
|
64
|
+
// engine instance that implements the same surface.
|
|
65
|
+
module.exports = new ExcelJSEngine();
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Redacted-workbook exporter.
|
|
2
|
+
//
|
|
3
|
+
// Reads an .xlsx as a ZIP, mutates only the *value* portions of each
|
|
4
|
+
// cell (and the shared-string + comment payloads) to typed placeholders,
|
|
5
|
+
// then repacks. Everything else — formulas, styles, sheet names, named
|
|
6
|
+
// ranges, feature parts (pivots / charts / queries / vba) — is passed
|
|
7
|
+
// through byte-for-byte where possible.
|
|
8
|
+
//
|
|
9
|
+
// Why ZIP-passthrough rather than ExcelJS round-trip:
|
|
10
|
+
// ExcelJS write() is lossy for many features (pivots, slicers,
|
|
11
|
+
// queries, conditional formatting, sparklines, threaded comments).
|
|
12
|
+
// For a bug-repro artifact we want maximum structural fidelity, so
|
|
13
|
+
// we operate at the XML-fragment level inside the existing ZIP.
|
|
14
|
+
//
|
|
15
|
+
// Placeholders:
|
|
16
|
+
// numbers → 0
|
|
17
|
+
// strings → "x"
|
|
18
|
+
// booleans → false (0)
|
|
19
|
+
// dates → 1900-01-01 (numeric date cells render to default date
|
|
20
|
+
// under their existing format; t="d" cells get the
|
|
21
|
+
// literal ISO string)
|
|
22
|
+
// errors → preserved as-is
|
|
23
|
+
//
|
|
24
|
+
// Comments and shared strings are also rewritten to "x" because they
|
|
25
|
+
// contain user text. Defined-name formulas are preserved (per spec).
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const JSZip = require('jszip');
|
|
30
|
+
|
|
31
|
+
// Match each <c ...>...</c> or self-closing <c .../> element.
|
|
32
|
+
// We deliberately restrict to a single regex pass per sheet — this is
|
|
33
|
+
// fragile only if a cell contains a nested <c> in user-supplied XML,
|
|
34
|
+
// which OOXML cells do not.
|
|
35
|
+
const CELL_RE = /<c\b([^>]*?)(\/>|>([\s\S]*?)<\/c>)/g;
|
|
36
|
+
|
|
37
|
+
// Cell type attribute extractor.
|
|
38
|
+
function getAttr(attrs, name) {
|
|
39
|
+
const m = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs);
|
|
40
|
+
return m ? m[1] : null;
|
|
41
|
+
}
|
|
42
|
+
function setAttr(attrs, name, value) {
|
|
43
|
+
if (new RegExp(`\\b${name}="`).test(attrs)) {
|
|
44
|
+
return attrs.replace(new RegExp(`\\b${name}="[^"]*"`), `${name}="${value}"`);
|
|
45
|
+
}
|
|
46
|
+
return `${attrs} ${name}="${value}"`;
|
|
47
|
+
}
|
|
48
|
+
function removeAttr(attrs, name) {
|
|
49
|
+
return attrs.replace(new RegExp(`\\s*\\b${name}="[^"]*"`), '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extract first <f ...>...</f> or <f .../> from a cell body. Preserve verbatim.
|
|
53
|
+
const F_RE = /<f\b[^>]*(?:\/>|>[\s\S]*?<\/f>)/;
|
|
54
|
+
|
|
55
|
+
function redactCell(match, attrs, selfOrBody, body) {
|
|
56
|
+
// Self-closing <c r="A1"/> — empty cell, nothing to redact.
|
|
57
|
+
if (selfOrBody === '/>') return match;
|
|
58
|
+
|
|
59
|
+
const t = getAttr(attrs, 't');
|
|
60
|
+
const fMatch = body.match(F_RE);
|
|
61
|
+
const formulaXml = fMatch ? fMatch[0] : '';
|
|
62
|
+
|
|
63
|
+
// Errors: preserve the value as-is. Cell type is "e".
|
|
64
|
+
if (t === 'e') {
|
|
65
|
+
return match;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Inline string: rebuild as <is><t>x</t></is>.
|
|
69
|
+
if (t === 'inlineStr') {
|
|
70
|
+
return `<c${attrs}>${formulaXml}<is><t>x</t></is></c>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Shared string: convert to inline string so we don't depend on the
|
|
74
|
+
// sst index meaning anything. (We also rewrite sst payloads to "x"
|
|
75
|
+
// for defense-in-depth, but this avoids index-collision worries.)
|
|
76
|
+
if (t === 's') {
|
|
77
|
+
let newAttrs = setAttr(attrs, 't', 'inlineStr');
|
|
78
|
+
return `<c${newAttrs}>${formulaXml}<is><t>x</t></is></c>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Formula returning a literal string.
|
|
82
|
+
if (t === 'str') {
|
|
83
|
+
return `<c${attrs}>${formulaXml}<v>x</v></c>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Boolean → false (0).
|
|
87
|
+
if (t === 'b') {
|
|
88
|
+
return `<c${attrs}>${formulaXml}<v>0</v></c>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ISO-date typed cell.
|
|
92
|
+
if (t === 'd') {
|
|
93
|
+
return `<c${attrs}>${formulaXml}<v>1900-01-01</v></c>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Default = number (no t attribute, or t="n"). Whether it's a date
|
|
97
|
+
// is encoded in the *style* (numFmt), not the cell type. By
|
|
98
|
+
// replacing the numeric value with 0, a date-styled cell will render
|
|
99
|
+
// as 1900-01-00 / 1900-01-01 depending on the date system in use,
|
|
100
|
+
// which is the documented placeholder.
|
|
101
|
+
return `<c${attrs}>${formulaXml}<v>0</v></c>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function redactSheetXml(xml) {
|
|
105
|
+
return xml.replace(CELL_RE, redactCell);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Shared strings: every <t>...</t> payload becomes "x". Preserves the
|
|
109
|
+
// number of unique strings + their indices so cells that happen to
|
|
110
|
+
// reference sst still resolve to a valid (redacted) string.
|
|
111
|
+
function redactSharedStringsXml(xml) {
|
|
112
|
+
// Replace inner text of every <t> element (handles <t>x</t> and
|
|
113
|
+
// <t xml:space="preserve">x</t>). Empty payloads stay empty.
|
|
114
|
+
return xml.replace(/(<t\b[^>]*>)([\s\S]*?)(<\/t>)/g, (m, open, payload, close) => {
|
|
115
|
+
return open + (payload === '' ? '' : 'x') + close;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Comments: <comment><text><r>...<t>USER TEXT</t></r></text></comment>
|
|
120
|
+
// Replace every <t> payload with "x".
|
|
121
|
+
function redactCommentsXml(xml) {
|
|
122
|
+
return xml.replace(/(<t\b[^>]*>)([\s\S]*?)(<\/t>)/g, (m, open, payload, close) => {
|
|
123
|
+
return open + (payload === '' ? '' : 'x') + close;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Threaded comments: <threadedComment ... text="USER TEXT" .../>
|
|
128
|
+
// Excel encodes the body as an attribute — must redact in place.
|
|
129
|
+
function redactThreadedCommentsXml(xml) {
|
|
130
|
+
return xml.replace(/\btext="[^"]*"/g, 'text="x"');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function exportRedactedWorkbook(inputPath, outputPath) {
|
|
134
|
+
if (!fs.existsSync(inputPath)) {
|
|
135
|
+
throw new Error(`File not found: ${inputPath}`);
|
|
136
|
+
}
|
|
137
|
+
const ext = path.extname(inputPath).toLowerCase();
|
|
138
|
+
if (ext !== '.xlsx' && ext !== '.xlsm') {
|
|
139
|
+
throw new Error(`--export-redacted-workbook only supports .xlsx / .xlsm (got ${ext})`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const buf = fs.readFileSync(inputPath);
|
|
143
|
+
const zip = await JSZip.loadAsync(buf);
|
|
144
|
+
|
|
145
|
+
const filenames = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
|
|
146
|
+
|
|
147
|
+
for (const name of filenames) {
|
|
148
|
+
const file = zip.file(name);
|
|
149
|
+
if (!file || file.dir) continue;
|
|
150
|
+
|
|
151
|
+
if (/^xl\/worksheets\/sheet\d+\.xml$/i.test(name)) {
|
|
152
|
+
const xml = await file.async('string');
|
|
153
|
+
zip.file(name, redactSheetXml(xml));
|
|
154
|
+
} else if (/^xl\/sharedStrings\.xml$/i.test(name)) {
|
|
155
|
+
const xml = await file.async('string');
|
|
156
|
+
zip.file(name, redactSharedStringsXml(xml));
|
|
157
|
+
} else if (/^xl\/comments\d+\.xml$/i.test(name)) {
|
|
158
|
+
const xml = await file.async('string');
|
|
159
|
+
zip.file(name, redactCommentsXml(xml));
|
|
160
|
+
} else if (/^xl\/threadedComments\/threadedComment\d+\.xml$/i.test(name)) {
|
|
161
|
+
const xml = await file.async('string');
|
|
162
|
+
zip.file(name, redactThreadedCommentsXml(xml));
|
|
163
|
+
}
|
|
164
|
+
// All other parts pass through untouched.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Use store-or-deflate matching Excel's defaults (deflate level 6).
|
|
168
|
+
const out = await zip.generateAsync({
|
|
169
|
+
type: 'nodebuffer',
|
|
170
|
+
compression: 'DEFLATE',
|
|
171
|
+
compressionOptions: { level: 6 },
|
|
172
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
173
|
+
});
|
|
174
|
+
fs.writeFileSync(outputPath, out);
|
|
175
|
+
return outputPath;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
exportRedactedWorkbook,
|
|
180
|
+
// exported for unit testing
|
|
181
|
+
_redactSheetXml: redactSheetXml,
|
|
182
|
+
_redactSharedStringsXml: redactSharedStringsXml,
|
|
183
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xlsx-for-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "CLI that converts .xlsx files into rich text or JSON dumps that AI coding agents (Claude, Cursor, Copilot, ChatGPT, etc.) can read — preserving values, formulas, formatting, colors, column widths, frozen panes, named ranges, tables, and more.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"index.js",
|
|
12
|
+
"lib",
|
|
12
13
|
"cursor-rule-template",
|
|
13
14
|
"README.md",
|
|
14
15
|
"WHY.md",
|
|
16
|
+
"SECURITY.md",
|
|
15
17
|
"LICENSE"
|
|
16
18
|
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/round-trip.test.js test/output-matrix.test.js test/unit/*.test.js"
|
|
21
|
+
},
|
|
17
22
|
"keywords": [
|
|
18
23
|
"xlsx",
|
|
19
24
|
"excel",
|
|
@@ -43,7 +48,7 @@
|
|
|
43
48
|
},
|
|
44
49
|
"dependencies": {
|
|
45
50
|
"@formulajs/formulajs": "^4.6.0",
|
|
46
|
-
"exceljs": "^4.4.0",
|
|
51
|
+
"@protobi/exceljs": "^4.4.0-protobi.9",
|
|
47
52
|
"gpt-tokenizer": "^3.4.0",
|
|
48
53
|
"papaparse": "^5.5.3",
|
|
49
54
|
"xlsx": "^0.18.5"
|
|
@@ -51,9 +56,6 @@
|
|
|
51
56
|
"devDependencies": {
|
|
52
57
|
"patch-package": "^8.0.1"
|
|
53
58
|
},
|
|
54
|
-
"scripts": {
|
|
55
|
-
"postinstall": "patch-package"
|
|
56
|
-
},
|
|
57
59
|
"overrides": {
|
|
58
60
|
"glob": "^13.0.0",
|
|
59
61
|
"rimraf": "^5.0.10",
|