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 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
- const ExcelJS = require('exceljs');
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 = Object.keys(ws._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 === ExcelJS.ValueType.Formula && typeof raw === 'object') {
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 = Object.keys(ws._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
- merges: Object.keys(ws._merges || {}),
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
- const wb = new ExcelJS.Workbook();
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 = new ExcelJS.Workbook();
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 an ExcelJS
926
- // Workbook so the rest of the code (dump/markdown/json/sql/schema) works
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 = new ExcelJS.Workbook();
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 = new ExcelJS.stream.xlsx.WorkbookReader(filePath, {
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 = new ExcelJS.Workbook();
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 wb.xlsx.writeFile(outPath);
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
- main().catch((err) => {
1868
- const msg = err && err.message ? err.message : String(err);
1869
- console.error(msg);
1870
- if (/Invalid string length/i.test(msg)) {
1871
- 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.');
1872
- }
1873
- process.exit(1);
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
+ };
@@ -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.4.3",
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",