@cj-tech-master/excelts 9.5.8 → 9.5.9

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.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WorkbookXform = void 0;
4
+ const errors_1 = require("../../../errors.js");
4
5
  const col_cache_1 = require("../../../utils/col-cache.js");
5
6
  const ooxml_paths_1 = require("../../../utils/ooxml-paths.js");
6
7
  const base_xform_1 = require("../base-xform.js");
@@ -92,43 +93,70 @@ class WorkbookXform extends base_xform_1.BaseXform {
92
93
  model.sheets = withIndex.map(entry => entry.sheet);
93
94
  }
94
95
  // collate all the print areas from all of the sheets and add them to the defined names
96
+ //
97
+ // OOXML (ECMA-376 §18.2.5) requires that the (name, localSheetId) pair
98
+ // is unique within `<definedNames>` — so multiple print areas on the
99
+ // same sheet must collapse into a *single* `<definedName>` whose text
100
+ // is a comma-separated list of ranges (the format Excel itself emits).
101
+ // The `printArea` field uses `&&` as the multi-range separator (a
102
+ // historical convention preserved for backwards compatibility); we
103
+ // also accept commas so users can paste Excel's native format.
104
+ //
105
+ // Both `printArea` and the `printTitlesRow`/`printTitlesColumn` fields
106
+ // are normalised through `parsePrintReference` before being emitted.
107
+ // This means the writer accepts any of the forms Excel itself accepts
108
+ // — `A1`, `A1:B5`, `$A$1:$B$5`, `a1:b5`, ` A1 : B5 `, `Sheet!A1:B5`,
109
+ // `'Q,F'!A1:B5`, whole-row `1:5`, whole-column `A:C` — and always
110
+ // emits the canonical `$col$row[:$col$row]` shape that Excel
111
+ // round-trips faithfully. Without normalisation, the previous
112
+ // string-concatenation path produced `$$A$1` for a `$A$1` input
113
+ // (corrupt), `$a1` for a `a1` input (Excel rejects), and
114
+ // `$A1:$B5` row-relative (semantically wrong for a print area).
95
115
  const printAreas = [];
96
116
  let index = 0; // sheets is sparse array - calc index manually
97
117
  model.sheets.forEach((sheet) => {
98
118
  if (sheet.pageSetup && sheet.pageSetup.printArea) {
99
- sheet.pageSetup.printArea.split("&&").forEach((printArea) => {
100
- const printAreaComponents = printArea.split(":");
101
- const start = printAreaComponents[0];
102
- const end = printAreaComponents[1] ?? start;
103
- const definedName = {
119
+ const ranges = [];
120
+ // Split on either `&&` (legacy excelts separator) or `,` (Excel's
121
+ // native separator) at the *top level* — commas / `&&` inside a
122
+ // quoted sheet name (`'Q1, Forecast'!A1:B5`) must NOT be treated
123
+ // as separators. A naive `split(/&&|,/)` shreds such inputs.
124
+ for (const segment of splitPrintAreaInput(sheet.pageSetup.printArea)) {
125
+ const normalised = normalisePrintAreaRange(segment, sheet.name);
126
+ if (normalised) {
127
+ ranges.push(normalised);
128
+ }
129
+ }
130
+ if (ranges.length > 0) {
131
+ printAreas.push({
104
132
  name: "_xlnm.Print_Area",
105
- ranges: [`'${sheet.name}'!$${start}:$${end}`],
133
+ ranges,
106
134
  localSheetId: index
107
- };
108
- printAreas.push(definedName);
109
- });
135
+ });
136
+ }
110
137
  }
111
138
  if (sheet.pageSetup &&
112
139
  (sheet.pageSetup.printTitlesRow || sheet.pageSetup.printTitlesColumn)) {
113
140
  const ranges = [];
114
141
  if (sheet.pageSetup.printTitlesColumn) {
115
- const titlesColumns = sheet.pageSetup.printTitlesColumn.split(":");
116
- const start = titlesColumns[0];
117
- const end = titlesColumns[1] ?? start;
118
- ranges.push(`'${sheet.name}'!$${start}:$${end}`);
142
+ const normalised = normalisePrintTitlesAxis(sheet.pageSetup.printTitlesColumn, sheet.name);
143
+ if (normalised) {
144
+ ranges.push(normalised);
145
+ }
119
146
  }
120
147
  if (sheet.pageSetup.printTitlesRow) {
121
- const titlesRows = sheet.pageSetup.printTitlesRow.split(":");
122
- const start = titlesRows[0];
123
- const end = titlesRows[1] ?? start;
124
- ranges.push(`'${sheet.name}'!$${start}:$${end}`);
148
+ const normalised = normalisePrintTitlesAxis(sheet.pageSetup.printTitlesRow, sheet.name);
149
+ if (normalised) {
150
+ ranges.push(normalised);
151
+ }
152
+ }
153
+ if (ranges.length > 0) {
154
+ printAreas.push({
155
+ name: "_xlnm.Print_Titles",
156
+ ranges,
157
+ localSheetId: index
158
+ });
125
159
  }
126
- const definedName = {
127
- name: "_xlnm.Print_Titles",
128
- ranges,
129
- localSheetId: index
130
- };
131
- printAreas.push(definedName);
132
160
  }
133
161
  index++;
134
162
  });
@@ -334,10 +362,14 @@ class WorkbookXform extends base_xform_1.BaseXform {
334
362
  model.definedNames.forEach((definedName) => {
335
363
  // For print area/titles, use rawText to extract ranges since the xform
336
364
  // layer no longer pre-classifies content (two-phase design).
365
+ // When falling back to rawText we must split on top-level commas
366
+ // (commas inside a quoted sheet name like `'Q1, Forecast'!$A$1` do
367
+ // *not* delimit ranges), so a naive `rawText.split(",")` is wrong
368
+ // and would mis-split sheet names with embedded commas.
337
369
  const effectiveRanges = definedName.ranges?.length > 0
338
370
  ? definedName.ranges
339
371
  : definedName.rawText
340
- ? [definedName.rawText]
372
+ ? splitPrintAreaInput(definedName.rawText)
341
373
  : [];
342
374
  if (definedName.name === "_xlnm.Print_Area") {
343
375
  worksheet = worksheets[definedName.localSheetId];
@@ -345,10 +377,52 @@ class WorkbookXform extends base_xform_1.BaseXform {
345
377
  if (!worksheet.pageSetup) {
346
378
  worksheet.pageSetup = {};
347
379
  }
348
- const range = col_cache_1.colCache.decodeEx(effectiveRanges[0]);
349
- worksheet.pageSetup.printArea = worksheet.pageSetup.printArea
350
- ? `${worksheet.pageSetup.printArea}&&${range.dimensions}`
351
- : range.dimensions;
380
+ // A print-area `<definedName>` may carry multiple ranges as a
381
+ // comma-separated list (Excel's native format) — read every
382
+ // range, not just the first. Rejoin with `&&` so the
383
+ // worksheet-level `printArea` field uses the legacy excelts
384
+ // separator (preserved for backwards compatibility on the
385
+ // public API; both `&&` and `,` are accepted on write).
386
+ //
387
+ // Route through the same `parsePrintReference` the writer
388
+ // uses so every legitimate Excel reference shape (cell,
389
+ // range, whole-row, whole-column) round-trips. The previous
390
+ // implementation called `colCache.decodeEx` directly, which
391
+ // returns a `NaN`-laced result for whole-row/column inputs
392
+ // (those are not cell addresses) — those legitimate shapes
393
+ // came back as `"NaN:NaN"` on the worksheet model.
394
+ const decoded = [];
395
+ for (const rangeStr of effectiveRanges) {
396
+ // Wrap in try/catch: `parsePrintReference` throws
397
+ // `ColumnOutOfBoundsError` (column past XFD) or
398
+ // `RowOutOfBoundsError` (row 0 or row past 1048576) for
399
+ // out-of-range refs. On the *write* side that throw
400
+ // surfaces a user error, but on this *read* side a
401
+ // malformed file (or one authored by another tool) must
402
+ // not blow up the whole load — drop the bad range and
403
+ // continue.
404
+ let ref;
405
+ try {
406
+ ref = parsePrintReference(rangeStr);
407
+ }
408
+ catch {
409
+ ref = undefined;
410
+ }
411
+ if (!ref) {
412
+ continue;
413
+ }
414
+ // Promote a bare cell to a degenerate range so the
415
+ // worksheet `printArea` field is always a range string —
416
+ // that's the documented API contract and matches what
417
+ // Excel itself emits for single-cell print areas.
418
+ decoded.push(ref.kind === "cell" ? `${ref.dimensions}:${ref.dimensions}` : ref.dimensions);
419
+ }
420
+ if (decoded.length > 0) {
421
+ const joined = decoded.join("&&");
422
+ worksheet.pageSetup.printArea = worksheet.pageSetup.printArea
423
+ ? `${worksheet.pageSetup.printArea}&&${joined}`
424
+ : joined;
425
+ }
352
426
  }
353
427
  }
354
428
  else if (definedName.name === "_xlnm.Print_Titles") {
@@ -406,3 +480,306 @@ WorkbookXform.STATIC_XFORMS = {
406
480
  $: { appName: "xl", lastEdited: 5, lowestEdited: 5, rupBuild: 9303 }
407
481
  })
408
482
  };
483
+ /**
484
+ * Split a print-area string on its multi-range separators while honouring
485
+ * single-quoted sheet name segments.
486
+ *
487
+ * Used by both the writer (parsing user-supplied `printArea` values) and
488
+ * the reader (parsing the body of an OOXML `<definedName>` when the
489
+ * defined-name layer hands us the raw text). Recognises both:
490
+ * - `,` — the OOXML / Excel-native separator
491
+ * - `&&` — the legacy excelts convention preserved on the public API
492
+ *
493
+ * Quoted sheet names (`'Q1, Forecast'!A1:B5`) are skipped over: any `,`,
494
+ * `&`, or `'` inside a quoted name is preserved verbatim. A doubled
495
+ * apostrophe inside a quoted segment (`''`) is the OOXML escape for a
496
+ * literal apostrophe and does not terminate the quote.
497
+ *
498
+ * Empty / whitespace-only segments are dropped; the caller normalises
499
+ * each surviving segment further with `parsePrintReference`.
500
+ */
501
+ function splitPrintAreaInput(input) {
502
+ const result = [];
503
+ let current = "";
504
+ let inQuote = false;
505
+ for (let i = 0; i < input.length; i++) {
506
+ const ch = input[i];
507
+ if (ch === "'") {
508
+ // Doubled apostrophe inside a quoted segment is an escaped literal.
509
+ if (inQuote && input[i + 1] === "'") {
510
+ current += "''";
511
+ i++;
512
+ continue;
513
+ }
514
+ inQuote = !inQuote;
515
+ current += ch;
516
+ continue;
517
+ }
518
+ if (!inQuote) {
519
+ if (ch === ",") {
520
+ if (current.trim()) {
521
+ result.push(current);
522
+ }
523
+ current = "";
524
+ continue;
525
+ }
526
+ if (ch === "&" && input[i + 1] === "&") {
527
+ if (current.trim()) {
528
+ result.push(current);
529
+ }
530
+ current = "";
531
+ i++; // skip the second `&`
532
+ continue;
533
+ }
534
+ }
535
+ current += ch;
536
+ }
537
+ if (current.trim()) {
538
+ result.push(current);
539
+ }
540
+ return result;
541
+ }
542
+ /**
543
+ * Quote a sheet name for inclusion in an OOXML defined-name reference.
544
+ *
545
+ * Per ECMA-376 §18.17 sheet names that contain spaces or any character
546
+ * outside `[A-Za-z0-9_]` MUST be wrapped in single quotes; a literal
547
+ * apostrophe inside the name is doubled. We always quote — over-quoting
548
+ * is harmless (Excel parses both forms) and keeps the writer trivial.
549
+ */
550
+ function quoteSheetName(sheetName) {
551
+ return `'${sheetName.replace(/'/g, "''")}'`;
552
+ }
553
+ /**
554
+ * Find the index of the first `!` that lies outside any single-quoted
555
+ * sheet-name segment, or `-1` if no unquoted `!` is present.
556
+ *
557
+ * Sheet names quoted with `'` may contain unbalanced characters, so we
558
+ * walk the string honouring quote toggles before declaring a `!` to be
559
+ * the sheet/address separator. Doubled apostrophes (`''`) inside a
560
+ * quoted name are treated as a literal apostrophe per OOXML.
561
+ */
562
+ function findUnquotedExclamation(value) {
563
+ let inQuote = false;
564
+ for (let i = 0; i < value.length; i++) {
565
+ const ch = value[i];
566
+ if (ch === "'") {
567
+ if (inQuote && value[i + 1] === "'") {
568
+ i++;
569
+ continue;
570
+ }
571
+ inQuote = !inQuote;
572
+ }
573
+ else if (ch === "!" && !inQuote) {
574
+ return i;
575
+ }
576
+ }
577
+ return -1;
578
+ }
579
+ // Excel 2007+ row limit. Print-area / print-titles references emitted
580
+ // to OOXML must respect this — Excel rejects definitions that point
581
+ // past the addressable sheet, so the writer normalises every row
582
+ // through `parseRowToken` (which throws `RowOutOfBoundsError` on
583
+ // overflow). The rest of the codebase tolerates higher row numbers in
584
+ // transient API calls (`getCell("A99999999")`) because those never
585
+ // reach the file format; print references do.
586
+ const EXCEL_MAX_ROW = 1048576;
587
+ /**
588
+ * Parse a row token (the digits portion of a cell reference, or a
589
+ * whole-row number) into a canonical integer string.
590
+ *
591
+ * Rejects:
592
+ * - row 0 (Excel rows are 1-indexed; `$A$0` is never a valid Excel ref)
593
+ * - rows beyond `EXCEL_MAX_ROW` (Excel hard limit)
594
+ *
595
+ * Normalises:
596
+ * - leading zeros (`001` → `1`) — OOXML expects bare integers, and
597
+ * `Number(...)` collapses any padding the user typed.
598
+ */
599
+ function parseRowToken(token) {
600
+ // The caller has already matched the token against `\d+`, so `Number`
601
+ // is safe (no NaN). We re-emit the canonical decimal form to drop
602
+ // leading zeros the user typed.
603
+ const n = Number(token);
604
+ if (n < 1) {
605
+ throw new errors_1.RowOutOfBoundsError(n, `Excel rows are 1-indexed; row ${token} is invalid`);
606
+ }
607
+ if (n > EXCEL_MAX_ROW) {
608
+ throw new errors_1.RowOutOfBoundsError(n, `Excel supports rows from 1 to ${EXCEL_MAX_ROW}`);
609
+ }
610
+ return String(n);
611
+ }
612
+ /**
613
+ * Parse a single user- or OOXML-supplied print reference into one of
614
+ * Excel's four valid shapes (cell / range / row / column), discarding
615
+ * any sheet prefix the input carries. Both the writer and the reader
616
+ * route through this single parser so the two sides agree on what is
617
+ * accepted and how it is canonicalised.
618
+ *
619
+ * Accepts every form Excel itself accepts on input, regardless of which
620
+ * side calls it:
621
+ * - cell: `A1`, `$A$1`, `Sheet1!$A$1`, `'Q,F'!$A$1`, `a1`
622
+ * - range: `A1:B5`, `$A$1:$B$5`, `Sheet!A1:B5`, ` A1 : B5 `, `a1:b5`,
623
+ * mixed `$A1:$B$5`, reversed `B5:A1` (canonicalised to `A1:B5`)
624
+ * - whole row: `1:5`, `$1:$5`, `Sheet!$1:$5`, `5` (single row),
625
+ * reversed `5:1` (canonicalised to `1:5`), padded `001:005`
626
+ * - whole column: `A:C`, `$A:$C`, `Sheet!$A:$C`, `C` (single column),
627
+ * `a:c`, reversed `C:A` (canonicalised to `A:C`)
628
+ *
629
+ * Returns `undefined` for inputs that do not match one of the four
630
+ * shapes — callers drop the entry rather than emit corrupt XML.
631
+ *
632
+ * **Throws**:
633
+ * - `ColumnOutOfBoundsError` when the input parses as a valid shape
634
+ * but references a column letter beyond Excel's XFD (16384) limit.
635
+ * - `RowOutOfBoundsError` for row 0 (Excel rows are 1-indexed) or
636
+ * rows beyond Excel's `1048576` limit.
637
+ *
638
+ * Both errors match what `getCell` and `colCache.l2n` already throw for
639
+ * the same inputs; surfacing them here means a user who hand-authors a
640
+ * malformed `printArea` finds out at write time rather than producing
641
+ * a workbook Excel silently rejects.
642
+ *
643
+ * Why a hand-rolled parser instead of `colCache.decodeEx`? `decodeEx`
644
+ * was designed for cell addresses and produces `NaN`-laced output for
645
+ * whole-row (`$1:$5`) and whole-column (`$A:$C`) references. Print
646
+ * areas and print titles legitimately use both, so we need a parser
647
+ * that recognises all four shapes uniformly *and* canonicalises
648
+ * reversed endpoints (which `decodeEx` does for cells but not for
649
+ * row/column references).
650
+ */
651
+ function parsePrintReference(input) {
652
+ if (typeof input !== "string") {
653
+ return undefined;
654
+ }
655
+ const trimmed = input.trim();
656
+ if (!trimmed) {
657
+ return undefined;
658
+ }
659
+ // Strip the sheet prefix if present (we anchor by `localSheetId`,
660
+ // never by the prefix). Any prefix the caller supplies is discarded.
661
+ const exclamation = findUnquotedExclamation(trimmed);
662
+ const body = exclamation === -1 ? trimmed : trimmed.slice(exclamation + 1);
663
+ // Strip every `$`, every whitespace, and upper-case the remaining
664
+ // letters in one pass. This subsumes mixed/redundant `$` signs
665
+ // (`$A1:$B$5` → `A1:B5`), surrounding/internal whitespace
666
+ // (`A1 : B5` → `A1:B5`), and lowercase columns (`a1:b5` → `A1:B5`).
667
+ const cleaned = body.replace(/[\s$]+/g, "").toUpperCase();
668
+ if (!cleaned) {
669
+ return undefined;
670
+ }
671
+ const parts = cleaned.split(":");
672
+ if (parts.length > 2) {
673
+ return undefined;
674
+ }
675
+ const startRaw = parts[0];
676
+ const endRaw = parts.length === 2 ? parts[1] : startRaw;
677
+ // Cell shape: both endpoints are full `<col><row>` addresses.
678
+ const cellRe = /^([A-Z]+)(\d+)$/;
679
+ const startCell = cellRe.exec(startRaw);
680
+ const endCell = cellRe.exec(endRaw);
681
+ if (startCell && endCell) {
682
+ // Validate column letters against Excel's XFD (16384) limit and
683
+ // rows against the `1..1048576` band. `l2n` and `parseRowToken`
684
+ // throw the project-standard errors here so the caller (and end
685
+ // user) sees a familiar diagnosis when they hand-author a bad ref.
686
+ // We keep the column numbers around to canonicalise reversed
687
+ // endpoints below without a second lookup.
688
+ const startColNum = col_cache_1.colCache.l2n(startCell[1]);
689
+ const endColNum = col_cache_1.colCache.l2n(endCell[1]);
690
+ const startRow = Number(parseRowToken(startCell[2]));
691
+ const endRow = Number(parseRowToken(endCell[2]));
692
+ // Canonicalise reversed endpoints. Excel's UI never produces
693
+ // `B5:A1`, but a hand-authored input might; downstream consumers
694
+ // (PDF layout, the OOXML reader's sort comparators) assume
695
+ // top-left → bottom-right ordering, so we sort here once.
696
+ const tlCol = startColNum <= endColNum ? startCell[1] : endCell[1];
697
+ const brCol = startColNum <= endColNum ? endCell[1] : startCell[1];
698
+ const tlRow = startRow <= endRow ? startRow : endRow;
699
+ const brRow = startRow <= endRow ? endRow : startRow;
700
+ // A bare cell (no `:` in the input) is the only true `cell` shape.
701
+ // `A1:A1` — a `:`-bearing range whose endpoints happen to coincide —
702
+ // is reported as `range`, matching the user's typed intent and
703
+ // avoiding the question of whether `parts.length === 2 && tl === br`
704
+ // should round-trip as a cell or a degenerate range.
705
+ if (parts.length === 1) {
706
+ return {
707
+ kind: "cell",
708
+ ooxml: `$${tlCol}$${tlRow}`,
709
+ dimensions: `${tlCol}${tlRow}`
710
+ };
711
+ }
712
+ return {
713
+ kind: "range",
714
+ ooxml: `$${tlCol}$${tlRow}:$${brCol}$${brRow}`,
715
+ dimensions: `${tlCol}${tlRow}:${brCol}${brRow}`
716
+ };
717
+ }
718
+ // Whole-row shape: both endpoints are bare row numbers.
719
+ if (/^\d+$/.test(startRaw) && /^\d+$/.test(endRaw)) {
720
+ const startRow = Number(parseRowToken(startRaw));
721
+ const endRow = Number(parseRowToken(endRaw));
722
+ const tl = Math.min(startRow, endRow);
723
+ const br = Math.max(startRow, endRow);
724
+ return {
725
+ kind: "row",
726
+ ooxml: `$${tl}:$${br}`,
727
+ dimensions: `${tl}:${br}`
728
+ };
729
+ }
730
+ // Whole-column shape: both endpoints are bare column letters. We
731
+ // reuse `l2n`'s already-validated index to canonicalise reversed
732
+ // endpoints — `colCache.l2n` is the project-wide source of truth for
733
+ // column ordering.
734
+ if (/^[A-Z]+$/.test(startRaw) && /^[A-Z]+$/.test(endRaw)) {
735
+ const startNum = col_cache_1.colCache.l2n(startRaw);
736
+ const endNum = col_cache_1.colCache.l2n(endRaw);
737
+ const tl = startNum <= endNum ? startRaw : endRaw;
738
+ const br = startNum <= endNum ? endRaw : startRaw;
739
+ return {
740
+ kind: "col",
741
+ ooxml: `$${tl}:$${br}`,
742
+ dimensions: `${tl}:${br}`
743
+ };
744
+ }
745
+ return undefined;
746
+ }
747
+ /**
748
+ * Normalise a user-supplied `printArea` value into the canonical OOXML
749
+ * `'Sheet'!<ref>` form. Returns `undefined` for malformed input so the
750
+ * caller drops the entry instead of emitting corrupt XML.
751
+ *
752
+ * `printArea` accepts cell, range, whole-row, and whole-column shapes —
753
+ * Excel itself supports all four (e.g. selecting entire columns A:C as
754
+ * the print area is a common UI gesture). Bare cell inputs are promoted
755
+ * to a degenerate range `$A$1:$A$1` because that is what Excel itself
756
+ * emits for a single-cell print area, and the worksheet API exposes
757
+ * `printArea` as a range string (single-cell entries surface as `A1:A1`).
758
+ */
759
+ function normalisePrintAreaRange(input, sheetName) {
760
+ const ref = parsePrintReference(input);
761
+ if (!ref) {
762
+ return undefined;
763
+ }
764
+ const ooxml = ref.kind === "cell" ? `${ref.ooxml}:${ref.ooxml}` : ref.ooxml;
765
+ return `${quoteSheetName(sheetName)}!${ooxml}`;
766
+ }
767
+ /**
768
+ * Normalise a user-supplied print-titles row or column expression into
769
+ * the canonical OOXML form `'Sheet'!$N:$N` (rows) or `'Sheet'!$L:$L`
770
+ * (columns).
771
+ *
772
+ * Long-standing excelts behaviour lets users put a column expression on
773
+ * `printTitlesRow` (and vice versa) — the OOXML reader has always
774
+ * re-classified the value onto the correct field on round-trip — so we
775
+ * honour that by letting the parser infer the actual axis from the
776
+ * input shape. Strict enforcement would silently drop print titles
777
+ * users have set successfully for years.
778
+ */
779
+ function normalisePrintTitlesAxis(input, sheetName) {
780
+ const ref = parsePrintReference(input);
781
+ if (!ref || (ref.kind !== "row" && ref.kind !== "col")) {
782
+ return undefined;
783
+ }
784
+ return `${quoteSheetName(sheetName)}!${ref.ooxml}`;
785
+ }
@@ -51,7 +51,15 @@ const colCache = {
51
51
  let l3;
52
52
  let n = 1;
53
53
  if (level >= 4) {
54
- throw new ColumnOutOfBoundsError(level, "Excel supports columns from 1 to 16384");
54
+ // Defensive invariant: Excel's column space (XFD = 16,384) caps at
55
+ // three letters, so neither `l2n` nor `n2l` should ever ask for a
56
+ // higher level. Both callers validate before reaching here; if
57
+ // this branch fires it indicates a programming error in a future
58
+ // caller, not a user input problem — surface that clearly rather
59
+ // than reusing `ColumnOutOfBoundsError` (which would lie about
60
+ // the offending column, since `level` is a letter-count, not a
61
+ // column number).
62
+ throw new Error(`colCache._fill: invariant violated — level ${level} exceeds the 3-letter cap; callers must validate before invoking _fill`);
55
63
  }
56
64
  if (this._l2nFill < 1 && level >= 1) {
57
65
  while (n <= 26) {
@@ -92,6 +100,16 @@ const colCache = {
92
100
  },
93
101
  l2n(l) {
94
102
  if (!this._l2n[l]) {
103
+ // Excel's column space stops at XFD (16,384) — three letters is
104
+ // the maximum width any valid column letter can have. Reject
105
+ // longer inputs explicitly here, BEFORE handing the length to
106
+ // `_fill`, so the thrown error carries the actual offending
107
+ // letter (`AAAA`) rather than the level integer (`4`) — matching
108
+ // what the equivalent `n2l(n > 16384)` and `decodeAddress` paths
109
+ // already report.
110
+ if (l.length > 3) {
111
+ throw new ColumnOutOfBoundsError(l, "Excel supports columns from 1 to 16384");
112
+ }
95
113
  this._fill(l.length);
96
114
  }
97
115
  if (!this._l2n[l]) {