@xlvane/compat 1.2.3 → 1.3.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/dist/index.d.ts CHANGED
@@ -408,9 +408,13 @@ interface CsvWriteOptions {
408
408
  /** fast-csv formatter options: `delimiter`, `rowDelimiter`, `quote`, `escape`, `quoteColumns`, `headers`, `writeHeaders`, `includeEndRowDelimiter`, `writeBOM`. */
409
409
  formatterOptions?: Record<string, unknown>;
410
410
  /**
411
- * `"escape"` (default): text that starts with `=`, `+`, `-`, `@`, tab, CR or LF (or their
412
- * full-width forms) is written with a leading `'`, so spreadsheet applications show it as text
413
- * (OWASP CSV Injection). `"off"`: written unchanged, as exceljs does. An xlvane option.
411
+ * `"escape"` (default): text that starts with `=`, `+`, `-`, `@`, tab, CR, LF or another
412
+ * control character (or the full-width forms of the signs), or with a byte order mark, is
413
+ * written with a leading `'`, so spreadsheet applications show it as text (OWASP CSV
414
+ * Injection); so is a `latin1` or `utf16le` field whose first bytes are a byte order mark, and
415
+ * a `utf16le` field whose bytes without NULs start with `=`. `utf16le` output starts with the
416
+ * UTF-16LE byte order mark. A field holding a comma, `;`, tab or `|` is quoted whatever the
417
+ * delimiter. `"off"`: written unchanged, as exceljs does. An xlvane option.
414
418
  */
415
419
  formulaInjection?: "escape" | "off";
416
420
  }
@@ -2452,6 +2456,8 @@ export declare class Cell {
2452
2456
  _master: Cell | null;
2453
2457
  /** @internal Number of merged ranges this cell is the master of. */
2454
2458
  _mergeCount: number;
2459
+ /** The moved formula of a `{ sharedFormula }` value, and what it was moved from (see `formula`). */
2460
+ private _moved;
2455
2461
  /** @internal Use `worksheet.getCell()`. */
2456
2462
  constructor(_row: Row, _col: number);
2457
2463
  get worksheet(): Worksheet;
@@ -2808,6 +2814,11 @@ export declare class Worksheet {
2808
2814
  _columns: Column[];
2809
2815
  /** @internal */
2810
2816
  _merges: MergeMap;
2817
+ /**
2818
+ * @internal Formulas of shared-formula masters by address, for the dependents of a worksheet
2819
+ * whose rows are not kept (stream.xlsx.WorkbookReader), so `cell.formula` can move them (K2).
2820
+ */
2821
+ _sharedMasters: Map<string, string> | undefined;
2811
2822
  /** The cells covered by `_merges` (row × 16 385 + column), valid while `_mergedVersion` equals `_merges.version`. */
2812
2823
  private _mergedCells;
2813
2824
  private _mergedVersion;
package/dist/index.js CHANGED
@@ -1434,6 +1434,8 @@ function toNewWorkbook(sheets, date1904, meta = {}) {
1434
1434
  }
1435
1435
  return c;
1436
1436
  };
1437
+ const styleFor = (cell) => useStyles ? cellStyleIn(cell, styleOf(cell.style)) : isDateCell(cell) ? { numFmt: 14 } : {};
1438
+ const uniform = uniformMerges(ws);
1437
1439
  ws._rows.forEach((row) => {
1438
1440
  if (!row) return;
1439
1441
  if (row.height !== void 0 || row.hidden || row.outlineLevel) sheet.row(row.number, {
@@ -1443,13 +1445,31 @@ function toNewWorkbook(sheets, date1904, meta = {}) {
1443
1445
  ...row.collapsed ? { collapsed: true } : {}
1444
1446
  });
1445
1447
  row._cells.forEach((cell) => {
1446
- if (cell) at(ws.name, cell.address, () => writeCell(cell));
1448
+ if (!cell) return;
1449
+ if (cell._master !== null && uniform.masters.has(cell._master)) {
1450
+ if (cell.note !== void 0) at(ws.name, cell.address, () => {
1451
+ const note = noteIn(cell);
1452
+ if (note) sheet.note(cell.address, note);
1453
+ });
1454
+ } else at(ws.name, cell.address, () => writeCell(cell));
1447
1455
  });
1448
1456
  });
1457
+ for (const m of uniform.ranges) {
1458
+ const master = ws.findCell(m.top, m.left);
1459
+ if (!master) continue;
1460
+ const style = styleFor(master);
1461
+ if (Object.keys(style).length === 0) continue;
1462
+ const width = m.right - m.left + 1;
1463
+ const step = Math.max(1, Math.floor(MERGE_STYLE_BLOCK / width));
1464
+ for (let top = m.top; top <= m.bottom; top += step) {
1465
+ const bottom = Math.min(m.bottom, top + step - 1);
1466
+ sheet.style(`${columnLetter(m.left)}${String(top)}:${columnLetter(m.right)}${String(bottom)}`, style);
1467
+ }
1468
+ }
1449
1469
  function writeCell(cell) {
1450
1470
  const note = noteIn(cell);
1451
1471
  if (note) sheet.note(cell.address, note);
1452
- const style = useStyles ? cellStyleIn(cell, styleOf(cell.style)) : isDateCell(cell) ? { numFmt: 14 } : {};
1472
+ const style = styleFor(cell);
1453
1473
  const value = cell.type === ValueType.Merge ? null : valueIn(cell, sharedRanges);
1454
1474
  if (value === null && Object.keys(style).length === 0) return;
1455
1475
  sheet.set(cell.address, value, style);
@@ -1485,6 +1505,55 @@ function toNewWorkbook(sheets, date1904, meta = {}) {
1485
1505
  for (const d of meta.definedNames ?? []) book.defineName(d.name, d.ranges.join(","));
1486
1506
  return book;
1487
1507
  }
1508
+ /** Cells styled by one `sheet.style` call for a merged range: at most what the core styles at once. */
1509
+ const MERGE_STYLE_BLOCK = 1048576;
1510
+ /** Whether two exceljs styles have the same parts (the same objects, as a merge's copies have). */
1511
+ function sameStyleParts(a, b) {
1512
+ if (a === b) return true;
1513
+ let n = 0;
1514
+ for (const k in a) {
1515
+ if (a[k] !== b[k]) return false;
1516
+ n++;
1517
+ }
1518
+ for (const _ in b) n--;
1519
+ return n === 0;
1520
+ }
1521
+ /**
1522
+ * The merged ranges of a worksheet whose other cells all exist, belong to the range's master and
1523
+ * carry the master's style (the copy `mergeCells` gives them, as in exceljs), and the masters all
1524
+ * of whose ranges are such. Their cells are written as the master's style over the range (K2),
1525
+ * and their notes one by one (K3).
1526
+ */
1527
+ function uniformMerges(ws) {
1528
+ const ranges = [];
1529
+ const masters = /* @__PURE__ */ new Set();
1530
+ const mixed = /* @__PURE__ */ new Set();
1531
+ for (const m of ws._merges.values()) {
1532
+ const master = ws.findCell(m.top, m.left);
1533
+ if (!master) continue;
1534
+ let ok = true;
1535
+ for (let r = m.top; ok && r <= m.bottom; r++) {
1536
+ const row = ws.findRow(r);
1537
+ for (let c = m.left; c <= m.right; c++) {
1538
+ if (r === m.top && c === m.left) continue;
1539
+ const cell = row?.findCell(c);
1540
+ if (!cell || cell._master !== master || !sameStyleParts(cell.style, master.style)) {
1541
+ ok = false;
1542
+ break;
1543
+ }
1544
+ }
1545
+ }
1546
+ if (ok) {
1547
+ ranges.push(m);
1548
+ masters.add(master);
1549
+ } else mixed.add(master);
1550
+ }
1551
+ for (const master of mixed) masters.delete(master);
1552
+ return {
1553
+ ranges: ranges.filter((m) => masters.has(ws.findCell(m.top, m.left))),
1554
+ masters
1555
+ };
1556
+ }
1488
1557
  /**
1489
1558
  * The validations, conditional formats, protection and printing settings of a worksheet in the
1490
1559
  * form of the core API (D-C06). exceljs always writes page margins and page setup
@@ -1598,6 +1667,18 @@ function viewOut(view, freeze) {
1598
1667
  };
1599
1668
  }
1600
1669
  /**
1670
+ * Whether a formula read by xlvane has text, without making the core build it: the core moves a
1671
+ * shared master's text to a dependent only when the dependent's `text` is read (a getter), and
1672
+ * gives `null` (a plain value) when it cannot move it. Reading the text of every dependent made a
1673
+ * small file with a huge master and many dependents exhaust memory (K2), and exceljs reads a
1674
+ * dependent as `{ sharedFormula }` without its text anyway.
1675
+ */
1676
+ function hasFormulaText(f) {
1677
+ const d = Object.getOwnPropertyDescriptor(f, "text");
1678
+ if (d !== void 0 && d.get !== void 0) return true;
1679
+ return f.text !== null;
1680
+ }
1681
+ /**
1601
1682
  * A formula read by xlvane in exceljs's shape (cell-xform.js L460–470; X4-29): a shared-formula
1602
1683
  * master `{ formula, result, ref, shareType: 'shared' }`, a dependent `{ sharedFormula: master,
1603
1684
  * result }` (its `cell.formula` is translated), an array formula `{ formula, result, ref,
@@ -2858,6 +2939,56 @@ function notSupported(feature) {
2858
2939
  details: { feature }
2859
2940
  });
2860
2941
  }
2942
+ /**
2943
+ * The shared-formula text moved per workbook (K4 S1, D-K04). A move costs time in proportion to the
2944
+ * master's length, and the load-time budget is spent once, so a 4 KB file whose 1 000 000-character
2945
+ * master is shared with 250 cells took about 47 s to read every dependent's formula once. Two
2946
+ * budgets, counting the master's text read plus the moved text built for each `cell.formula` that
2947
+ * is not kept yet:
2948
+ * - every move: 512 Mi characters, the default `maxSharedStringChars` with which xlvane's
2949
+ * `readWorkbook` expands shared formulas, so a workbook the core reads is read here too;
2950
+ * - moves of masters longer than 8192 characters, the longest formula Office writes ([MS-OI29500]
2951
+ * §2.1.1085 a): 32 Mi characters, so such a master stops after a few moves.
2952
+ */
2953
+ const SHARED_FORMULA_TEXT = 536870912;
2954
+ const LONG_SHARED_FORMULA_TEXT = 33554432;
2955
+ const LONG_FORMULA = 8192;
2956
+ /** Characters moved in all and for long masters, per workbook (a `Worksheet.workbook`). */
2957
+ const sharedFormulaSpent = /* @__PURE__ */ new WeakMap();
2958
+ /**
2959
+ * @internal Moves the text of a shared-formula master at `master` to `row`/`col` of `sheet` with
2960
+ * `shiftFormula`, counted against the workbook's shared-formula budgets; `undefined` when the
2961
+ * formula cannot be moved. Throws `XLV_E_LIMIT_EXCEEDED` before a move that would pass one.
2962
+ */
2963
+ function moveSharedFormula(workbook, sheet, text, master, row, col) {
2964
+ let spent = sharedFormulaSpent.get(workbook);
2965
+ if (spent === void 0) sharedFormulaSpent.set(workbook, spent = {
2966
+ chars: 0,
2967
+ long: 0
2968
+ });
2969
+ const cost = 2 * text.length;
2970
+ const long = text.length > LONG_FORMULA;
2971
+ const limit = long && spent.long + cost > LONG_SHARED_FORMULA_TEXT ? LONG_SHARED_FORMULA_TEXT : spent.chars + cost > SHARED_FORMULA_TEXT ? SHARED_FORMULA_TEXT : null;
2972
+ if (limit !== null) {
2973
+ const cell = `${columnLetter(col)}${String(row)}`;
2974
+ throw new XlvError("XLV_E_LIMIT_EXCEEDED", `Moving the shared formula of ${columnLetter(master.col)}${String(master.row)} (${String(text.length)} characters) to ${cell} of worksheet "${sheet}" would bring the shared-formula text moved in this workbook past ${String(limit)} characters.`, {
2975
+ location: {
2976
+ sheet,
2977
+ cell
2978
+ },
2979
+ hint: "The workbook shares very long formulas with many cells. Read the dependents' cached result (cell.result) instead of cell.formula, or read the file with xlvane's readWorkbook, whose shared-formula text is limited by limits.maxSharedStringChars.",
2980
+ details: {
2981
+ limit: "sharedFormulaText",
2982
+ value: limit
2983
+ }
2984
+ });
2985
+ }
2986
+ const moved = shiftFormula(text, row - master.row, col - master.col);
2987
+ const used = text.length + (moved?.length ?? 0);
2988
+ spent.chars += used;
2989
+ if (long) spent.long += used;
2990
+ return moved ?? void 0;
2991
+ }
2861
2992
  /** Column letters for a 1-based number (A … XFD). */
2862
2993
  function columnLetter(n) {
2863
2994
  let s = "";
@@ -2902,7 +3033,7 @@ function typeOf(value) {
2902
3033
  if (value instanceof Date) return ValueType.Date;
2903
3034
  if (isRecord(value)) {
2904
3035
  if (value["text"] && value["hyperlink"]) return ValueType.Hyperlink;
2905
- if (value["formula"] || value["sharedFormula"]) return ValueType.Formula;
3036
+ if (value["sharedFormula"] || value["formula"]) return ValueType.Formula;
2906
3037
  if (value["richText"]) return ValueType.RichText;
2907
3038
  if (value["sharedString"]) return ValueType.SharedString;
2908
3039
  if (value["error"]) return ValueType.Error;
@@ -2951,6 +3082,8 @@ var Cell = class {
2951
3082
  _master = null;
2952
3083
  /** @internal Number of merged ranges this cell is the master of. */
2953
3084
  _mergeCount = 0;
3085
+ /** The moved formula of a `{ sharedFormula }` value, and what it was moved from (see `formula`). */
3086
+ _moved = void 0;
2954
3087
  /** @internal Use `worksheet.getCell()`. */
2955
3088
  constructor(_row, _col) {
2956
3089
  this._row = _row;
@@ -3036,12 +3169,39 @@ var Cell = class {
3036
3169
  const v = this.value;
3037
3170
  if (!isRecord(v)) return void 0;
3038
3171
  if (typeof v["formula"] === "string") return v["formula"];
3039
- if (typeof v["sharedFormula"] === "string") {
3040
- const masterRef = v["sharedFormula"].replace(/\$/g, "");
3172
+ const shared = v["sharedFormula"];
3173
+ if (typeof shared === "string") {
3174
+ const masterRef = shared.replace(/\$/g, "");
3041
3175
  const master = this.worksheet.findCell(masterRef);
3042
- const text = master?.formula;
3043
- if (!master || text === void 0 || master === this) return void 0;
3044
- return shiftFormula(text, this.row - master.row, this.col - master.col) ?? void 0;
3176
+ let text;
3177
+ let at;
3178
+ if (!master) {
3179
+ text = this.worksheet._sharedMasters?.get(masterRef.toUpperCase());
3180
+ if (text === void 0) return void 0;
3181
+ at = decodeAddress(masterRef);
3182
+ } else {
3183
+ if (master === this) return void 0;
3184
+ text = master.formula;
3185
+ if (text === void 0) return void 0;
3186
+ at = {
3187
+ row: master.row,
3188
+ col: master.col
3189
+ };
3190
+ }
3191
+ const kept = this._moved;
3192
+ if (kept !== void 0 && kept.value === v && kept.shared === shared && kept.master === text && kept.masterRow === at.row && kept.masterCol === at.col && kept.row === this.row && kept.col === this.col) return kept.text;
3193
+ const moved = moveSharedFormula(this.worksheet.workbook, this.worksheet.name, text, at, this.row, this.col);
3194
+ this._moved = {
3195
+ value: v,
3196
+ shared,
3197
+ master: text,
3198
+ masterRow: at.row,
3199
+ masterCol: at.col,
3200
+ row: this.row,
3201
+ col: this.col,
3202
+ text: moved
3203
+ };
3204
+ return moved;
3045
3205
  }
3046
3206
  }
3047
3207
  /** `FormulaType.Shared` for a `{ sharedFormula }` value, `Master` for other formulas, otherwise `None` (lib/doc/cell.js L807). */
@@ -3782,6 +3942,11 @@ var Worksheet = class {
3782
3942
  _columns = [];
3783
3943
  /** @internal */
3784
3944
  _merges = new MergeMap();
3945
+ /**
3946
+ * @internal Formulas of shared-formula masters by address, for the dependents of a worksheet
3947
+ * whose rows are not kept (stream.xlsx.WorkbookReader), so `cell.formula` can move them (K2).
3948
+ */
3949
+ _sharedMasters = void 0;
3785
3950
  /** The cells covered by `_merges` (row × 16 385 + column), valid while `_mergedVersion` equals `_merges.version`. */
3786
3951
  _mergedCells = /* @__PURE__ */ new Set();
3787
3952
  _mergedVersion = -1;
@@ -4698,8 +4863,12 @@ const INJECTION_START = /* @__PURE__ */ new Set([
4698
4863
  "﹦",
4699
4864
  "﹢",
4700
4865
  "﹣",
4701
- "﹫"
4866
+ "﹫",
4867
+ "",
4868
+ "￾"
4702
4869
  ]);
4870
+ /** Characters a field is quoted for while formulas are escaped, whatever the delimiter: other applications may split on them (K2). */
4871
+ const COMMON_DELIMITERS = ",; |";
4703
4872
  function invalid(name, message, hint) {
4704
4873
  return new XlvError("XLV_E_INVALID_OPTION", message, {
4705
4874
  hint,
@@ -4985,9 +5154,41 @@ function malformed(line, message) {
4985
5154
  details: { line }
4986
5155
  });
4987
5156
  }
4988
- /** Splits CSV text into rows of fields as fast-csv 4.3 does with the given parser options. */
4989
- function parseCsv(text, o) {
4990
- const rows = [];
5157
+ /** Rows and columns of a worksheet (Part 1 §18.3.1.73, ST_CellRef up to XFD1048576). */
5158
+ const MAX_SHEET_ROWS = 1048576;
5159
+ const MAX_SHEET_COLUMNS = 16384;
5160
+ /** Fields added to the worksheet in total, as the core's `maxCells` read limit (K3). */
5161
+ const MAX_CSV_CELLS = DEFAULT_READ_LIMITS.maxCells;
5162
+ function tooMany(what, line) {
5163
+ if (what === "cells") return new XlvError("XLV_E_LIMIT_EXCEEDED", `CSV line ${String(line)}: the CSV has more than ${String(MAX_CSV_CELLS)} fields in the rows read, the most csv.read adds to a worksheet.`, {
5164
+ location: { line },
5165
+ hint: "Split the CSV file into smaller parts, read only part of it with parserOptions.maxRows or skipRows, or read it with the core fromCSV, whose maxCells option can be raised for a trusted file.",
5166
+ details: {
5167
+ limit: "maxCells",
5168
+ value: MAX_CSV_CELLS,
5169
+ line
5170
+ }
5171
+ });
5172
+ const rows = what === "rows";
5173
+ const max = rows ? MAX_SHEET_ROWS : MAX_SHEET_COLUMNS;
5174
+ return new XlvError("XLV_E_LIMIT_EXCEEDED", `CSV line ${String(line)}: ${rows ? `the CSV has more than ${String(max)} rows` : `the row has more than ${String(max)} fields`}, more than a worksheet can hold.`, {
5175
+ location: { line },
5176
+ hint: rows ? "Split the CSV file into parts of at most 1048576 rows, or read only part of it with parserOptions.maxRows." : "Split the CSV file into parts of at most 16384 columns, or check the delimiter (parserOptions.delimiter).",
5177
+ details: {
5178
+ limit: rows ? "maxRows" : "maxColumns",
5179
+ value: max,
5180
+ line
5181
+ }
5182
+ });
5183
+ }
5184
+ /**
5185
+ * The rows of CSV text, split as fast-csv 4.3 does with the given parser options, one at a time:
5186
+ * the rows that `ignoreEmpty`, the header, `skipRows` and `maxRows` drop are left out. More than
5187
+ * 1 048 576 rows, or a row of more than 16 384 fields, throw `XLV_E_LIMIT_EXCEEDED` as soon as
5188
+ * they are reached, because a worksheet cannot hold them (K2); so do more than 5 000 000 fields
5189
+ * in the rows given (`DEFAULT_READ_LIMITS.maxCells`, K3).
5190
+ */
5191
+ function* csvRows(text, o) {
4991
5192
  let at = 0;
4992
5193
  let line = 1;
4993
5194
  const n = text.length;
@@ -4996,6 +5197,10 @@ function parseCsv(text, o) {
4996
5197
  if (text[i] === "\r" && text[i + 1] === "\n") return i + 2;
4997
5198
  return i < n ? i + 1 : i;
4998
5199
  };
5200
+ const trim = (f) => o.trim ? f.trim() : o.ltrim ? f.trimStart() : o.rtrim ? f.trimEnd() : f;
5201
+ let kept = 0;
5202
+ let emitted = 0;
5203
+ let cells = 0;
4999
5204
  for (let k = 0; k < o.skipLines && at < n; k++) {
5000
5205
  while (!atLineEnd(at)) at++;
5001
5206
  at = skipLineEnd(at);
@@ -5008,38 +5213,50 @@ function parseCsv(text, o) {
5008
5213
  line++;
5009
5214
  continue;
5010
5215
  }
5216
+ const rowLine = line;
5011
5217
  const fields = [];
5218
+ let extra = 0;
5219
+ let extraText = false;
5220
+ const push = (field) => {
5221
+ if (fields.length < MAX_SHEET_COLUMNS) fields.push(field);
5222
+ else {
5223
+ extra++;
5224
+ if (field.trim() !== "") extraText = true;
5225
+ }
5226
+ };
5012
5227
  for (;;) {
5013
5228
  let start = at;
5014
5229
  while (start < n && (text[start] === " " || text[start] === " ") && text[start] !== o.delimiter) start++;
5015
5230
  if (o.quote !== "" && text[start] === o.quote) {
5016
5231
  let value = "";
5017
5232
  let i = start + 1;
5233
+ let from = i;
5018
5234
  const opened = line;
5019
5235
  for (;;) {
5020
5236
  if (i >= n) throw malformed(opened, `missing closing ${o.quote} for the field that starts here.`);
5021
5237
  const ch = text[i];
5022
5238
  if (ch === o.escape && text[i + 1] === o.quote) {
5023
- value += o.quote;
5239
+ value += text.slice(from, i) + o.quote;
5024
5240
  i += 2;
5241
+ from = i;
5025
5242
  continue;
5026
5243
  }
5027
5244
  if (ch === o.quote) {
5245
+ value += text.slice(from, i);
5028
5246
  i++;
5029
5247
  break;
5030
5248
  }
5031
5249
  if (ch === "\n") line++;
5032
- value += ch;
5033
5250
  i++;
5034
5251
  }
5035
5252
  while (i < n && (text[i] === " " || text[i] === " ") && text[i] !== o.delimiter) i++;
5036
5253
  if (i < n && text[i] !== o.delimiter && text[i] !== "\n" && text[i] !== "\r") throw malformed(line, `unexpected text after the closing ${o.quote}.`);
5037
- fields.push(value);
5254
+ push(value);
5038
5255
  at = i;
5039
5256
  } else {
5040
5257
  let i = at;
5041
5258
  while (i < n && text[i] !== o.delimiter && text[i] !== "\n" && text[i] !== "\r") i++;
5042
- fields.push(text.slice(at, i));
5259
+ push(text.slice(at, i));
5043
5260
  at = i;
5044
5261
  }
5045
5262
  if (at < n && text[at] === o.delimiter) {
@@ -5051,14 +5268,30 @@ function parseCsv(text, o) {
5051
5268
  at = skipLineEnd(at);
5052
5269
  line++;
5053
5270
  const row = fields.length === 1 && fields[0].trim() === "" ? [""] : fields;
5054
- rows.push(row.map((f) => o.trim ? f.trim() : o.ltrim ? f.trimStart() : o.rtrim ? f.trimEnd() : f));
5271
+ if (o.ignoreEmpty && !extraText && row.every((f) => f.trim() === "")) continue;
5272
+ kept++;
5273
+ if (o.skipHeader && kept === 1) continue;
5274
+ if (kept - (o.skipHeader ? 1 : 0) <= o.skipRows) continue;
5275
+ if (o.maxRows > 0 && emitted >= o.maxRows) continue;
5276
+ if (extra > 0) throw tooMany("columns", rowLine);
5277
+ if (++emitted > MAX_SHEET_ROWS) throw tooMany("rows", rowLine);
5278
+ cells += row.length;
5279
+ if (cells > MAX_CSV_CELLS) throw tooMany("cells", rowLine);
5280
+ yield {
5281
+ fields: row.map(trim),
5282
+ line: rowLine
5283
+ };
5055
5284
  }
5056
- let out = rows;
5057
- if (o.ignoreEmpty) out = out.filter((r) => r.some((f) => f.trim() !== ""));
5058
- if (o.skipHeader) out = out.slice(1);
5059
- if (o.skipRows > 0) out = out.slice(o.skipRows);
5060
- if (o.maxRows > 0) out = out.slice(0, o.maxRows);
5061
- return out;
5285
+ }
5286
+ /** The delimiters and line-break characters in `text`: at least the number of fields less one. */
5287
+ function fieldEnds(text, delimiter) {
5288
+ const d = delimiter.charCodeAt(0);
5289
+ let n = 0;
5290
+ for (let i = 0; i < text.length; i++) {
5291
+ const c = text.charCodeAt(i);
5292
+ if (c === d || c === 10 || c === 13) n++;
5293
+ }
5294
+ return n;
5062
5295
  }
5063
5296
  /**
5064
5297
  * exceljs's default conversion of a CSV field (lib/csv/csv.js L57–83), with two fixes: text of
@@ -5078,7 +5311,7 @@ function csvValue(datum, dateFormats) {
5078
5311
  if (special !== void 0) return typeof special === "object" ? { ...special } : special;
5079
5312
  return datum;
5080
5313
  }
5081
- function formatterOptions(raw) {
5314
+ function formatterOptions(raw, escapeFormulas) {
5082
5315
  const o = raw ?? {};
5083
5316
  const delimiter = typeof o["delimiter"] === "string" ? o["delimiter"] : ",";
5084
5317
  const rowDelimiter = typeof o["rowDelimiter"] === "string" ? o["rowDelimiter"] : "\n";
@@ -5095,9 +5328,27 @@ function formatterOptions(raw) {
5095
5328
  headers: Array.isArray(o["headers"]) && o["writeHeaders"] !== false ? o["headers"].map(String) : null,
5096
5329
  includeEndRowDelimiter: o["includeEndRowDelimiter"] === true,
5097
5330
  writeBOM: o["writeBOM"] === true,
5098
- escapeChars: new RegExp(`[${escapeRe(delimiter)}${escapeRe(rowDelimiter)}|\\r|\\n]`)
5331
+ escapeChars: new RegExp(`[${escapeRe(delimiter)}${escapeRe(rowDelimiter)}|\\r|\\n${escapeFormulas ? escapeRe(COMMON_DELIMITERS) : ""}]`),
5332
+ strictQuoting: escapeFormulas
5099
5333
  };
5100
5334
  }
5335
+ /**
5336
+ * Whether an application that splits `text` at a comma, ";", tab or "|" would get a field that
5337
+ * starts like a formula: one of those characters followed by a formula sign, a control character
5338
+ * or a byte order mark, after any spaces or tabs and an opening double quote (K2).
5339
+ */
5340
+ function splitStartsFormula(text) {
5341
+ for (let i = 0; i < text.length; i++) {
5342
+ if (!COMMON_DELIMITERS.includes(text[i])) continue;
5343
+ let j = i + 1;
5344
+ while (text[j] === " " || text[j] === " ") j++;
5345
+ if (text[j] === "\"") j++;
5346
+ while (text[j] === " ") j++;
5347
+ const c = text[j];
5348
+ if (c !== void 0 && (INJECTION_START.has(c) || c.charCodeAt(0) < 32)) return true;
5349
+ }
5350
+ return false;
5351
+ }
5101
5352
  /** One field as fast-csv FieldFormatter.format writes it. */
5102
5353
  function formatField(value, index, f, forceQuote) {
5103
5354
  const text = `${value === null || value === void 0 ? "" : String(value)}`.replace(/\0/g, "");
@@ -5108,6 +5359,13 @@ function formatField(value, index, f, forceQuote) {
5108
5359
  field: index + 1
5109
5360
  }
5110
5361
  });
5362
+ if (f.quote === "" && f.strictQuoting && splitStartsFormula(text)) throw new XlvError("XLV_E_INVALID_OPTION", `Field ${String(index + 1)} has a comma, ";", tab or "|" followed by a formula sign, which an application splitting on that character reads as a formula; it cannot be written with quote: false.`, {
5363
+ hint: "Leave formatterOptions.quote at its default (\") so such fields are quoted, or pass formulaInjection: \"off\" for a trusted consumer.",
5364
+ details: {
5365
+ option: "quote",
5366
+ field: index + 1
5367
+ }
5368
+ });
5111
5369
  if (f.quote !== "" && text.includes(f.quote)) return `${f.quote}${text.split(f.quote).join(f.escapedQuote)}${f.quote}`;
5112
5370
  const q = f.quoteColumns;
5113
5371
  const quoteThis = forceQuote || q === true || Array.isArray(q) && q[index] === true;
@@ -5127,7 +5385,7 @@ function defaultWriteMap(dateFormat, dateUTC) {
5127
5385
  if (typeof value === "object") {
5128
5386
  const v = value;
5129
5387
  if (v["text"] || v["hyperlink"]) return v["hyperlink"] || v["text"] || "";
5130
- if (v["formula"] || v["sharedFormula"] || "result" in v) {
5388
+ if (v["sharedFormula"] || v["formula"] || "result" in v) {
5131
5389
  const result = v["result"];
5132
5390
  if (result === void 0 || result === null) return "";
5133
5391
  if (result instanceof Date) return date(result);
@@ -5140,15 +5398,165 @@ function defaultWriteMap(dateFormat, dateUTC) {
5140
5398
  return value;
5141
5399
  };
5142
5400
  }
5401
+ /** The output encoding for the `encoding` option (an unsupported one is refused when encoding). */
5402
+ function outputEncoding(encoding) {
5403
+ const e = (encoding ?? "").toLowerCase();
5404
+ if (e === "latin1" || e === "binary") return "latin1";
5405
+ if (e === "utf16le" || e === "utf-16le" || e === "ucs2" || e === "ucs-2") return "utf16le";
5406
+ return "utf8";
5407
+ }
5408
+ /** Leading UTF-8 sequences of U+FF1D, U+FF0B, U+FF0D, U+FF20, U+FE66, U+FE62, U+FE63, U+FE6B. */
5409
+ const UTF8_FORMULA_SIGNS = [
5410
+ [
5411
+ 239,
5412
+ 188,
5413
+ 157
5414
+ ],
5415
+ [
5416
+ 239,
5417
+ 188,
5418
+ 139
5419
+ ],
5420
+ [
5421
+ 239,
5422
+ 188,
5423
+ 141
5424
+ ],
5425
+ [
5426
+ 239,
5427
+ 188,
5428
+ 160
5429
+ ],
5430
+ [
5431
+ 239,
5432
+ 185,
5433
+ 166
5434
+ ],
5435
+ [
5436
+ 239,
5437
+ 185,
5438
+ 162
5439
+ ],
5440
+ [
5441
+ 239,
5442
+ 185,
5443
+ 163
5444
+ ],
5445
+ [
5446
+ 239,
5447
+ 185,
5448
+ 171
5449
+ ]
5450
+ ];
5451
+ /**
5452
+ * Whether the first bytes of a field, as latin1 or UTF-16LE writes it, are a byte order mark
5453
+ * (EF BB BF, FF FE, FE FF), which makes a reader decode the rest of the file in that encoding
5454
+ * and drop the mark, or the UTF-8 form of a full-width or small formula sign, which a reader that
5455
+ * guesses UTF-8 shows as one (K2). `prefix` holds bytes written before the field (latin1 writes
5456
+ * the `writeBOM` mark U+FEFF as the single byte FF). UTF-16LE bytes are taken without their NUL
5457
+ * bytes, as an 8-bit reader that drops them reads them (K3); a sequence that holds no NUL byte is
5458
+ * the same either way.
5459
+ */
5460
+ function encodedStartIsUnsafe(text, encoding, prefix) {
5461
+ if (encoding === "utf8") return false;
5462
+ const bytes = [...prefix];
5463
+ for (let i = 0; bytes.length < 3 && i < text.length; i++) {
5464
+ const c = text.charCodeAt(i);
5465
+ if (encoding === "latin1") bytes.push(c & 255);
5466
+ else for (const b of [c & 255, c >> 8]) if (b !== 0) bytes.push(b);
5467
+ }
5468
+ const starts = (lead) => lead.every((b, i) => bytes[i] === b);
5469
+ return starts([
5470
+ 239,
5471
+ 187,
5472
+ 191
5473
+ ]) || starts([255, 254]) || starts([254, 255]) || UTF8_FORMULA_SIGNS.some(starts);
5474
+ }
5475
+ /** Bytes that end a field or a row for a reader of the 8-bit text, whatever its delimiter. */
5476
+ const BYTE_BREAKS = /* @__PURE__ */ new Set([
5477
+ 44,
5478
+ 59,
5479
+ 9,
5480
+ 124,
5481
+ 13,
5482
+ 10
5483
+ ]);
5484
+ /** Whether the 8-bit text `view` has a formula start at `i`: "=", or spaces or tabs, a quote and "=". */
5485
+ function viewFormulaAt(view, i) {
5486
+ if (view[i] === "=") return true;
5487
+ let j = i;
5488
+ while (view[j] === " " || view[j] === " ") j++;
5489
+ return view[j] === "\"" && view[j + 1] === "=";
5490
+ }
5143
5491
  /**
5144
- * The CSV text of a worksheet, as exceljs `csv.write` gives it.
5492
+ * Whether the UTF-16LE bytes of `text` without NULs start a formula (see {@link viewFormulaAt})
5493
+ * because of a character above U+00FF, such as U+3D00 (00 3D) or U+4E3D (3D 4E). Starts made only
5494
+ * of characters up to U+00FF are the same text in both readings and are checked as text.
5495
+ */
5496
+ function utf16StartsFormula(text) {
5497
+ let view = "";
5498
+ let foreign = false;
5499
+ let lead = 0;
5500
+ let blank = true;
5501
+ for (let i = 0; i < text.length && view.length - lead < 2; i++) {
5502
+ const c = text.charCodeAt(i);
5503
+ if (c > 255) foreign = true;
5504
+ for (const b of c > 255 ? [c & 255, c >> 8] : [c]) {
5505
+ if (b === 0) continue;
5506
+ view += String.fromCharCode(b);
5507
+ if (blank && (b === 32 || b === 9)) lead++;
5508
+ else blank = false;
5509
+ }
5510
+ }
5511
+ return foreign && viewFormulaAt(view, 0);
5512
+ }
5513
+ /** The UTF-16LE bytes of a written field without NULs, and whether characters above U+00FF give a break or a quote among them. */
5514
+ function utf16View(written, delimiter) {
5515
+ let view = "";
5516
+ let foreignBreak = false;
5517
+ let foreignQuote = false;
5518
+ for (let i = 0; i < written.length; i++) {
5519
+ const c = written.charCodeAt(i);
5520
+ for (const b of c > 255 ? [c & 255, c >> 8] : [c]) {
5521
+ if (b === 0) continue;
5522
+ if (c > 255) {
5523
+ if (b === 34) foreignQuote = true;
5524
+ else if (BYTE_BREAKS.has(b) || delimiter.includes(String.fromCharCode(b))) foreignBreak = true;
5525
+ }
5526
+ view += String.fromCharCode(b);
5527
+ }
5528
+ }
5529
+ return {
5530
+ view,
5531
+ foreignBreak,
5532
+ foreignQuote
5533
+ };
5534
+ }
5535
+ /** Whether the 8-bit text `view` has a delimiter (`delimiter` or a common one) or a line break followed by a formula start. */
5536
+ function viewSplitStartsFormula(view, delimiter) {
5537
+ for (let i = 0; i < view.length; i++) {
5538
+ if (BYTE_BREAKS.has(view.charCodeAt(i)) && viewFormulaAt(view, i + 1)) return true;
5539
+ if (view.startsWith(delimiter, i) && viewFormulaAt(view, i + delimiter.length)) return true;
5540
+ }
5541
+ return false;
5542
+ }
5543
+ /** Column letters of a 1-based column number. */
5544
+ function columnName(n) {
5545
+ let s = "";
5546
+ for (let c = n; c > 0; c = Math.floor((c - 1) / 26)) s = String.fromCharCode(65 + (c - 1) % 26) + s;
5547
+ return s;
5548
+ }
5549
+ /**
5550
+ * The CSV text of a worksheet, as exceljs `csv.write` gives it, for output in `encoding`.
5145
5551
  *
5146
5552
  * @internal
5147
5553
  */
5148
- function worksheetCsv(sheet, options, latin1 = false) {
5149
- const f = formatterOptions(options.formatterOptions);
5554
+ function worksheetCsv(sheet, options, encoding = "utf8") {
5150
5555
  const injection = options.formulaInjection ?? "escape";
5151
5556
  if (injection !== "escape" && injection !== "off") throw invalid("formulaInjection", "The option \"formulaInjection\" must be \"escape\" or \"off\".", "Use \"escape\" (default) or \"off\".");
5557
+ const f = formatterOptions(options.formatterOptions, injection === "escape");
5558
+ const latin1 = encoding === "latin1";
5559
+ const sheetName = sheet?.name ?? "";
5152
5560
  if (latin1) {
5153
5561
  for (const [name, value] of [
5154
5562
  ["delimiter", f.delimiter],
@@ -5159,6 +5567,10 @@ function worksheetCsv(sheet, options, latin1 = false) {
5159
5567
  }
5160
5568
  if (injection === "escape") {
5161
5569
  const off = "or pass formulaInjection: \"off\" for a trusted consumer.";
5570
+ if (f.delimiter === "") throw invalid("delimiter", "The delimiter is empty, so the fields of a row run together and the text of one field could complete a formula or a byte order mark begun by the one before.", `Use "," (default), ";", "|" or "\\t", ${off}`);
5571
+ if (encoding !== "utf8") {
5572
+ for (const [name, value] of [["delimiter", f.delimiter], ["rowDelimiter", f.rowDelimiter]]) if (/[^\u0000-\u007f]/.test(value)) throw invalid(name, `The option "${name}" (${JSON.stringify(value)}) has characters other than ASCII, whose ${encoding} bytes could join the bytes of the fields around them into a byte order mark or a formula sign.`, `Use ASCII characters with encoding "${encoding}" (for example "," or ";"), write UTF-8 (the default encoding), ${off}`);
5573
+ }
5162
5574
  if (f.delimiter !== " " && [...f.delimiter].some((c) => INJECTION_START.has(c) || c.charCodeAt(0) < 32)) throw invalid("delimiter", `The delimiter ${JSON.stringify(f.delimiter)} holds a formula sign or a control character, so a line could start with a formula.`, `Use "," (default), ";", "|" or "\\t", ${off}`);
5163
5575
  if (![
5164
5576
  "\n",
@@ -5170,8 +5582,34 @@ function worksheetCsv(sheet, options, latin1 = false) {
5170
5582
  }
5171
5583
  /** The text latin1 will write: characters above U+00FF as "?". */
5172
5584
  const narrow = (out) => latin1 && typeof out === "string" ? out.replace(/[^\u0000-\u00ff]/g, "?") : out;
5173
- /** A formatted field, refused if a standard reader would take its leading quote as quoting. */
5174
- const field = (out, index, force) => {
5585
+ const utf16 = encoding === "utf16le" && injection === "escape";
5586
+ const marked = injection === "escape" && (utf16 || latin1 && f.writeBOM);
5587
+ /** A quote may be taken differently by a reader of the 8-bit text from here on (K3). */
5588
+ let quoteShifted = false;
5589
+ const unsafeField = (index, row, reason, hint) => {
5590
+ const cell = `${columnName(index + 1)}${String(row)}`;
5591
+ return new XlvError("XLV_E_INVALID_OPTION", `The text of ${row === 0 ? `header ${String(index + 1)}` : `cell ${cell}`} cannot be written safely as ${encoding}: ${reason}`, {
5592
+ location: row === 0 ? { sheet: sheetName } : {
5593
+ sheet: sheetName,
5594
+ cell
5595
+ },
5596
+ hint,
5597
+ details: {
5598
+ option: "encoding",
5599
+ field: index + 1,
5600
+ row
5601
+ }
5602
+ });
5603
+ };
5604
+ /**
5605
+ * A formatted field, refused if a standard reader would take its leading quote as quoting, or
5606
+ * if a reader of the output as 8-bit text that drops NUL bytes (LibreOffice) would split it
5607
+ * before a formula start: in utf16le output, when characters above U+00FF give a delimiter,
5608
+ * line break or quote there, when an earlier quote of that kind may shift the quoting, or for
5609
+ * the first field after a mark that reader keeps (K3). `row` is the row number, 0 for the header
5610
+ * line; `atStart`: the field is the first one of the file.
5611
+ */
5612
+ const field = (out, index, force, row, atStart) => {
5175
5613
  const text = formatField(out, index, f, force);
5176
5614
  if (injection === "escape" && f.quote !== "\"" && text.startsWith("\"")) throw new XlvError("XLV_E_INVALID_OPTION", `Field ${String(index + 1)} starts with a double quote, which a standard CSV reader takes as quoting when formatterOptions.quote is not '"'.`, {
5177
5615
  hint: "Leave formatterOptions.quote at its default (\"), or pass formulaInjection: \"off\" for a trusted consumer.",
@@ -5180,20 +5618,32 @@ function worksheetCsv(sheet, options, latin1 = false) {
5180
5618
  field: index + 1
5181
5619
  }
5182
5620
  });
5621
+ const first = marked && atStart;
5622
+ if (first || quoteShifted || utf16 && /[^\u0000-\u00ff]/.test(text)) {
5623
+ const { view, foreignBreak, foreignQuote } = utf16 ? utf16View(text, f.delimiter) : {
5624
+ view: text,
5625
+ foreignBreak: false,
5626
+ foreignQuote: false
5627
+ };
5628
+ if ((first || foreignBreak || foreignQuote || quoteShifted) && viewSplitStartsFormula(view, f.delimiter)) throw first ? unsafeField(index, row, `the file starts with ${utf16 ? "the UTF-16LE byte order mark (FF FE)" : "the byte FF (writeBOM)"}, which LibreOffice keeps when it reads the file as 8-bit text, so the opening quote of this first field is not taken as quoting and its delimiter or line break followed by "=" starts a formula.`, utf16 ? "Write UTF-8 (the default encoding), move this text out of the first cell, or pass formulaInjection: \"off\" for a trusted consumer." : "Leave formatterOptions.writeBOM off with latin1, write UTF-8, or pass formulaInjection: \"off\" for a trusted consumer.") : utf16 ? unsafeField(index, row, "read as 8-bit text without its NUL bytes, as LibreOffice reads UTF-16LE CSV files, its bytes hold a delimiter or a line break followed by \"=\", which starts a formula there.", "Write UTF-8 (the default encoding), or pass formulaInjection: \"off\" for a trusted consumer.") : unsafeField(index, row, "the first field, after the byte FF of writeBOM, is split by LibreOffice, which may then take the quotes after it differently, and this field holds a delimiter or a line break followed by \"=\".", "Leave formatterOptions.writeBOM off with latin1, write UTF-8, or pass formulaInjection: \"off\" for a trusted consumer.");
5629
+ if (foreignQuote || first && ([...view].some((c) => BYTE_BREAKS.has(c.charCodeAt(0))) || view.includes(f.delimiter))) quoteShifted = true;
5630
+ }
5183
5631
  return text;
5184
5632
  };
5185
5633
  const map = options.map ?? defaultWriteMap(options.dateFormat, options.dateUTC === true);
5186
5634
  const includeEmptyRows = options.includeEmptyRows === void 0 || options.includeEmptyRows;
5187
5635
  const lines = [];
5188
- const escape = (raw) => {
5189
- const out = narrow(raw);
5636
+ const escape = (raw, atStart) => {
5637
+ const out = narrow(raw === null || raw === void 0 || typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint" || typeof raw === "string" ? raw : String(raw));
5190
5638
  if (injection !== "escape" || typeof out !== "string") return [out, false];
5191
- const first = out.replace(/\0/g, "")[0];
5192
- return first !== void 0 && (INJECTION_START.has(first) || first.charCodeAt(0) < 32) ? [`'${out}`, f.quote !== ""] : [out, false];
5639
+ const text = out.replace(/\0/g, "");
5640
+ const first = text[0];
5641
+ if (first === void 0) return [out, false];
5642
+ return INJECTION_START.has(first) || first.charCodeAt(0) < 32 || encodedStartIsUnsafe(text, encoding, atStart && f.writeBOM && latin1 ? [255] : []) || utf16 && utf16StartsFormula(text) ? [`'${out}`, f.quote !== ""] : [out, false];
5193
5643
  };
5194
5644
  if (f.headers) lines.push(f.headers.map((h, i) => {
5195
- const [out, force] = escape(h);
5196
- return field(out, i, force);
5645
+ const [out, force] = escape(h, i === 0);
5646
+ return field(out, i, force, 0, i === 0);
5197
5647
  }).join(f.delimiter));
5198
5648
  if (sheet) {
5199
5649
  let lastRow = 0;
@@ -5207,8 +5657,9 @@ function worksheetCsv(sheet, options, latin1 = false) {
5207
5657
  fields.push("");
5208
5658
  continue;
5209
5659
  }
5210
- const [out, force] = escape(map(values[i], i));
5211
- fields.push(field(out, i, force));
5660
+ const atStart = lines.length === 0 && i === 0;
5661
+ const [out, force] = escape(map(values[i], i), atStart);
5662
+ fields.push(field(out, i, force, rowNumber, atStart));
5212
5663
  }
5213
5664
  lines.push(fields.join(f.delimiter));
5214
5665
  lastRow = rowNumber;
@@ -5216,7 +5667,7 @@ function worksheetCsv(sheet, options, latin1 = false) {
5216
5667
  }
5217
5668
  let text = lines.join(f.rowDelimiter);
5218
5669
  if (f.includeEndRowDelimiter) text += f.rowDelimiter;
5219
- return (f.writeBOM ? "" : "") + text;
5670
+ return (f.writeBOM || utf16 ? "" : "") + text;
5220
5671
  }
5221
5672
  function encode(text, encoding) {
5222
5673
  const e = (encoding ?? "utf8").toLowerCase();
@@ -5358,10 +5809,16 @@ var CsvIO = class {
5358
5809
  async read(stream, options = {}) {
5359
5810
  const worksheet = this.workbook.addWorksheet(options.sheetName);
5360
5811
  const parser = parserOptions(options.parserOptions);
5361
- const rows = parseCsv(await readText(stream, parser.encoding), parser);
5812
+ const text = await readText(stream, parser.encoding);
5362
5813
  const formats = options.dateFormats ?? DEFAULT_DATE_FORMATS;
5363
5814
  const map = options.map ?? ((datum) => csvValue(datum, formats));
5364
- for (const row of rows) worksheet.addRow(row.map((datum, i) => map(datum, i)));
5815
+ if (text.length >= MAX_CSV_CELLS && fieldEnds(text, parser.delimiter) >= MAX_CSV_CELLS) try {
5816
+ const rows = csvRows(text, parser);
5817
+ while (rows.next().done !== true);
5818
+ } catch (error) {
5819
+ if (error instanceof XlvError && error.code === "XLV_E_LIMIT_EXCEEDED" && error.details?.limit === "maxCells") throw error;
5820
+ }
5821
+ for (const row of csvRows(text, parser)) worksheet.addRow(row.fields.map((datum, i) => map(datum, i)));
5365
5822
  return worksheet;
5366
5823
  }
5367
5824
  /** Reads a CSV file (Node.js, Bun, Deno) into a new worksheet. */
@@ -5389,8 +5846,7 @@ var CsvIO = class {
5389
5846
  }
5390
5847
  /** Writes a worksheet to a CSV file (Node.js, Bun, Deno), replacing it. */
5391
5848
  async writeFile(path, options = {}) {
5392
- const e = (options.encoding ?? "").toLowerCase();
5393
- const bytes = encode(this.text(options, e === "latin1" || e === "binary"), options.encoding);
5849
+ const bytes = encode(this.text(options, outputEncoding(options.encoding)), options.encoding);
5394
5850
  const fs = await nodeFs("writeFile");
5395
5851
  try {
5396
5852
  await fs.writeFile(path, bytes);
@@ -5414,7 +5870,7 @@ var CsvIO = class {
5414
5870
  createInputStream() {
5415
5871
  throw new Error("`CSV#createInputStream` is deprecated. You should use `CSV#read` instead. This method will be removed in version 5.0. Please follow upgrade instruction: https://github.com/exceljs/exceljs/blob/master/UPGRADE-4.0.md");
5416
5872
  }
5417
- text(options, latin1 = false) {
5873
+ text(options, encoding = "utf8") {
5418
5874
  const key = options.sheetName || options.sheetId;
5419
5875
  const sheet = this.workbook.getWorksheet(key);
5420
5876
  if (key !== void 0 && !sheet) throw new XlvError("XLV_E_SHEET_NOT_FOUND", `The workbook has no worksheet ${typeof key === "string" ? `named "${key}"` : `with id ${String(key)}`}.`, {
@@ -5422,7 +5878,7 @@ var CsvIO = class {
5422
5878
  hint: `Available worksheets: ${this.workbook.worksheets.map((w) => `"${w.name}"`).join(", ") || "none"}.`,
5423
5879
  details: { available: this.workbook.worksheets.map((w) => w.name) }
5424
5880
  });
5425
- return worksheetCsv(sheet, options, latin1);
5881
+ return worksheetCsv(sheet, options, encoding);
5426
5882
  }
5427
5883
  };
5428
5884
  // src/defined-names.ts
@@ -6286,6 +6742,23 @@ var Workbook = class {
6286
6742
  }
6287
6743
  });
6288
6744
  };
6745
+ const mergeBudget = Math.max(MERGED_CELLS_BUDGET, cellsRead);
6746
+ let merged = 0;
6747
+ const spendMerge = (positions, ref, sheet) => {
6748
+ merged += positions;
6749
+ if (merged <= mergeBudget) return;
6750
+ throw new XlvError("XLV_E_LIMIT_EXCEEDED", `The merged range ${ref} of worksheet "${sheet}" would bring the merged cells past ${String(mergeBudget)} for ${String(cellsRead)} cells in the file.`, {
6751
+ location: {
6752
+ sheet,
6753
+ cell: ref
6754
+ },
6755
+ hint: "The file merges far more cells than it holds. Load it with ignoreNodes: [\"mergeCells\"] to skip merged ranges, or read it with xlvane's readWorkbook, which does not create these cells.",
6756
+ details: {
6757
+ limit: "mergedCells",
6758
+ value: mergeBudget
6759
+ }
6760
+ });
6761
+ };
6289
6762
  const rowBudget = Math.max(67108864, 16 * cellsRead);
6290
6763
  const validations = validationBudget();
6291
6764
  let rowsLooked = 0;
@@ -6398,7 +6871,7 @@ var Workbook = class {
6398
6871
  const target = ws.getCell(cell.row, cell.col);
6399
6872
  let value;
6400
6873
  const plain = cell.type === "error" ? { error: cell.value } : cell.value;
6401
- if (cell.formula && cell.formula.text !== null) value = formulaOut(cell.formula, plain, cell.ref, masters);
6874
+ if (cell.formula && hasFormulaText(cell.formula)) value = formulaOut(cell.formula, plain, cell.ref, masters);
6402
6875
  else value = cell.richText ? richTextOut(cell.richText) : plain;
6403
6876
  const h = cell.hyperlink;
6404
6877
  if (h && use("hyperlinks") && (h.target !== null || h.location !== null && h.location !== "")) {
@@ -6440,6 +6913,7 @@ var Workbook = class {
6440
6913
  for (const m of use("mergeCells") ? s.merges : []) {
6441
6914
  if (!m.includes(":")) continue;
6442
6915
  spend(areaOf(m), "A merged range", s.name);
6916
+ spendMerge(areaOf(m), m, s.name);
6443
6917
  try {
6444
6918
  ws.mergeCells(m);
6445
6919
  } catch (error) {
@@ -6483,6 +6957,8 @@ var Workbook = class {
6483
6957
  }
6484
6958
  }
6485
6959
  };
6960
+ /** Merged cells `xlsx.load` creates at most for a file with fewer cells (one whole column; K2). */
6961
+ const MERGED_CELLS_BUDGET = 1048576;
6486
6962
  /**
6487
6963
  * @internal The chunks of an input stream, read until it ends. Reading stops with
6488
6964
  * XLV_E_LIMIT_EXCEEDED as soon as the chunks pass maxInputBytes, the size the core reads at most,
@@ -6571,7 +7047,7 @@ function streamValueIn(cell, masters) {
6571
7047
  const v = cell.value;
6572
7048
  if (v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date)) {
6573
7049
  const r = v;
6574
- if (typeof r["formula"] === "string" && r["shareType"] !== "array" && typeof r["ref"] === "string") {
7050
+ if (typeof r["ref"] === "string" && r["shareType"] !== "array" && typeof r["formula"] === "string") {
6575
7051
  masters.set(cell.address, {
6576
7052
  formula: r["formula"],
6577
7053
  row: cell.row,
@@ -7178,6 +7654,44 @@ var WorksheetReader = class extends Emitter {
7178
7654
  }
7179
7655
  };
7180
7656
  /**
7657
+ * The value of a shared-formula dependent read by the streaming reader: `{ sharedFormula: master,
7658
+ * result }`, as exceljs's xlsx.load gives it, with a non-enumerable `formula` that moves the
7659
+ * master's text (`masters`, by address) to `row`/`col` when it is first read. The master's row is
7660
+ * not kept, so a sheet the value is copied into (stream.xlsx.WorkbookWriter, whose committed rows
7661
+ * are dropped) can write this formula instead of looking the master up (K3); reading the rows
7662
+ * alone builds no moved texts (K2). Setting `formula` replaces it with an ordinary property. A move
7663
+ * counts against the shared-formula budget of `workbook`, as `cell.formula` does (K4 S1).
7664
+ */
7665
+ function sharedDependent(master, result, masters, row, col, workbook, sheet) {
7666
+ const value = {
7667
+ sharedFormula: master,
7668
+ ...result
7669
+ };
7670
+ let moved;
7671
+ let built = false;
7672
+ Object.defineProperty(value, "formula", {
7673
+ configurable: true,
7674
+ enumerable: false,
7675
+ get() {
7676
+ if (!built) {
7677
+ const text = masters.get(master);
7678
+ moved = text === void 0 ? void 0 : moveSharedFormula(workbook, sheet, text, decodeAddress(master), row, col);
7679
+ built = true;
7680
+ }
7681
+ return moved;
7682
+ },
7683
+ set(formula) {
7684
+ Object.defineProperty(this, "formula", {
7685
+ value: formula,
7686
+ writable: true,
7687
+ enumerable: true,
7688
+ configurable: true
7689
+ });
7690
+ }
7691
+ });
7692
+ return value;
7693
+ }
7694
+ /**
7181
7695
  * The streaming reader of exceljs (`ExcelJS.stream.xlsx.WorkbookReader`): iterate worksheets
7182
7696
  * and their rows with `for await`, or call `read()` and listen to `worksheet`, `end`,
7183
7697
  * `finished` and `error` events (and `entry` with `entries: 'emit'`). Rows are not kept.
@@ -7227,16 +7741,31 @@ var WorkbookReader = class extends Emitter {
7227
7741
  });
7228
7742
  const sheet = host._addReadWorksheet(host.nextId, info.name, { state: info.state });
7229
7743
  const name = info.name;
7744
+ const masters = /* @__PURE__ */ new Map();
7745
+ const kept = /* @__PURE__ */ new Map();
7746
+ sheet._sharedMasters = kept;
7230
7747
  const rows = async function* () {
7231
7748
  for await (const r of book.rows(name)) {
7232
7749
  const row = sheet.getRow(r.number);
7233
7750
  for (const cell of r.cells) {
7234
7751
  const plain = cell.type === "error" ? { error: cell.value } : cell.value;
7235
7752
  const target = row.getCell(cell.col);
7236
- target.value = cell.formula && cell.formula.text !== null ? plain === null ? { formula: cell.formula.text } : {
7237
- formula: cell.formula.text,
7238
- result: plain
7239
- } : cell.richText ? richTextOut(cell.richText) : plain;
7753
+ const f = cell.formula;
7754
+ const result = plain === null ? {} : { result: plain };
7755
+ let value;
7756
+ if (f && f.kind === "shared" && f.sharedIndex !== null && f.ref === null && masters.has(f.sharedIndex) && hasFormulaText(f)) value = sharedDependent(masters.get(f.sharedIndex), result, kept, r.number, cell.col, host, name);
7757
+ else if (f && hasFormulaText(f)) {
7758
+ const text = f.text ?? "";
7759
+ if (f.kind === "shared" && f.sharedIndex !== null && f.ref !== null) {
7760
+ masters.set(f.sharedIndex, cell.ref);
7761
+ kept.set(cell.ref, text);
7762
+ }
7763
+ value = {
7764
+ formula: text,
7765
+ ...result
7766
+ };
7767
+ } else value = cell.richText ? richTextOut(cell.richText) : plain;
7768
+ target.value = value;
7240
7769
  if (styles) target.style = styleOut(cell.style, cell.numFmt);
7241
7770
  }
7242
7771
  for (const cell of r.styledCells) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xlvane/compat",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "The commonly used exceljs 4.4.0 API on top of xlvane, for switching with one line in package.json.",
5
5
  "keywords": [
6
6
  "xlsx",
@@ -72,7 +72,7 @@
72
72
  "build": "tsdown"
73
73
  },
74
74
  "dependencies": {
75
- "xlvane": "^1.2.3"
75
+ "xlvane": "^1.3.0"
76
76
  },
77
77
  "publishConfig": {
78
78
  "access": "public",