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