From 31deaf393a0e3d1e5d56dbfad7c43af3670533fd Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Sun, 29 Mar 2026 07:44:34 +0000 Subject: [PATCH 1/3] fix: 3D range where middle looks like a ternary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With allowTernary:true and mergeRefs:false, the A1 lexer parsed "F2:B" in "B!F2:B!F20" as a ternary range, consuming the "B" from the second sheet prefix "B!". This corrupted cross-sheet range references during A1→R1C1 conversion. Fix: canEndPartialRange now rejects "!" so a ternary range cannot end where a sheet prefix begins. "F2:B!" is no longer valid as a ternary range endpoint. --- lib/lexers/canEndRange.ts | 6 +++++- lib/translateToR1C1.spec.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/lexers/canEndRange.ts b/lib/lexers/canEndRange.ts index d188fc4..e2ccd9d 100644 --- a/lib/lexers/canEndRange.ts +++ b/lib/lexers/canEndRange.ts @@ -11,6 +11,9 @@ export function canEndRange (str: string, pos: number): boolean { } // partial: [A-Za-z0-9_($.] +// Also rejects "!" — a ternary range must not end where a sheet prefix +// begins (e.g. "F2:B" in "B!F2:B!F20" is not a ternary range; the +// trailing "B" is the start of the second sheet prefix "B!"). export function canEndPartialRange (str: string, pos: number): boolean { const c = str.charCodeAt(pos); return !( @@ -20,6 +23,7 @@ export function canEndPartialRange (str: string, pos: number): boolean { (c === 95) || // _ (c === 40) || // ( (c === 36) || // $ - (c === 46) // . + (c === 46) || // . + (c === 33) // ! ); } diff --git a/lib/translateToR1C1.spec.ts b/lib/translateToR1C1.spec.ts index 03d394f..5d02c83 100644 --- a/lib/translateToR1C1.spec.ts +++ b/lib/translateToR1C1.spec.ts @@ -154,6 +154,19 @@ describe('translate involved cases from A1 to RC', () => { }); }); +describe('translate cross-sheet ranges', () => { + test('sheet prefix not consumed as ternary range end', () => { + // B!F2:B!F20 from B137 — the second B! is a sheet prefix, not a + // ternary range endpoint. Without the fix, the lexer with + // allowTernary:true parses "F2:B" as a ternary range, consuming + // the B from the second sheet prefix. + isA2R('=B!F2:B!F20', 'B137', '=B!R[-135]C[4]:B!R[-117]C[4]'); + isA2R('=SUM(Sheet1!A1:Sheet1!A10)', 'C5', '=SUM(Sheet1!R[-4]C[-2]:Sheet1!R[5]C[-2])'); + // Single-letter sheet names that look like column letters + isA2R('=X!D3:X!D10', 'A1', '=X!R[2]C[3]:X!R[9]C[3]'); + }); +}); + describe('translate works with merged ranges', () => { test('preserves token metadata and locations', () => { // This tests that: From 22e616df0c3113af568afdff6bb837336eec6331 Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Sun, 29 Mar 2026 22:56:26 +0000 Subject: [PATCH 2/3] Quote column-letter-like sheet names in A1 prefix serialization Sheet names that consist of 1-3 letters (A-Z, AA-ZZ, AAA-XFD) are valid column references. When used as sheet prefixes in ranges like B!F2:B!F20, the second B can be misread as a column letter by parsers. Excel quotes these in XLSX output: B!F2:'B'!F20. Add reIsColumnLike check in stringifyPrefixXlsx so that sheet names matching /^[A-Z]{1,3}$/i are quoted. This matches Excel's XLSX serialization behavior. --- lib/fixRanges.spec.ts | 2 +- lib/stringifyPrefix.ts | 8 +++++++- lib/translateToA1.spec.ts | 26 ++++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/fixRanges.spec.ts b/lib/fixRanges.spec.ts index d1d78fc..bb33a0b 100644 --- a/lib/fixRanges.spec.ts +++ b/lib/fixRanges.spec.ts @@ -226,7 +226,7 @@ describe('fixRanges works with xlsx mode', () => { isFixed('=[Workbook]!Table[Column]', '=[Workbook]!Table[Column]', opts); isFixed('=[Lorem Ipsum]!Table[Column]', "='[Lorem Ipsum]'!Table[Column]", opts); isFixed("='[Foo]'!A1", '=[Foo]!A1', opts); - isFixed('=[Foo]Bar!A1', '=[Foo]Bar!A1', opts); + isFixed('=[Foo]Bar!A1', "='[Foo]Bar'!A1", opts); isFixed('=[Foo Bar]Baz!A1', "='[Foo Bar]Baz'!A1", opts); isFixed('=[Foo]!A1', '=[Foo]!A1', opts); isFixed('=[Lorem Ipsum]!A1', "='[Lorem Ipsum]'!A1", opts); diff --git a/lib/stringifyPrefix.ts b/lib/stringifyPrefix.ts index fb622c5..0b86afc 100644 --- a/lib/stringifyPrefix.ts +++ b/lib/stringifyPrefix.ts @@ -12,6 +12,9 @@ import type { const reBannedChars = /[^0-9A-Za-z._¡¤§¨ª\u00ad¯-\uffff]/; // A1-XFD1048575 | R | C | RC const reIsRangelike = /^(R|C|RC|[A-Z]{1,3}\d{1,7})$/i; +// Bare column letters (A-XFD) — need quoting when used as sheet names +// because they're ambiguous after ":" in ranges like B!F2:B!F20 +const reIsColumnLike = /^[A-Z]{1,3}$/i; export function needQuotes (scope: string, yesItDoes = 0): number { if (yesItDoes) { @@ -66,7 +69,10 @@ export function stringifyPrefixXlsx ( } if (sheetName) { pre += sheetName; - quote += needQuotes(sheetName); + // Quote sheet names that look like column letters (A-XFD) to avoid + // ambiguity in ranges like B!F2:B!F20, where the second B could be + // mistaken for a column. Excel quotes these in XLSX: B!F2:'B'!F20. + quote += needQuotes(sheetName) || (reIsColumnLike.test(sheetName) ? 1 : 0); } if (quote) { pre = quotePrefix(pre); diff --git a/lib/translateToA1.spec.ts b/lib/translateToA1.spec.ts index a0b34ba..8129d16 100644 --- a/lib/translateToA1.spec.ts +++ b/lib/translateToA1.spec.ts @@ -211,11 +211,11 @@ describe('translate works with xlsx mode references', () => { ]); expect(translateTokensToA1([ { type: 'range', value: 'foo!R1C' } ], 'B2')) - .toEqual([ { type: 'range', value: 'foo!B$1' } ]); + .toEqual([ { type: 'range', value: "'foo'!B$1" } ]); expect(translateTokensToA1([ { type: 'range', value: '[foo]!R1C' } ], 'B2')) .toEqual([ { type: 'range', value: '[foo]!B$1' } ]); expect(translateTokensToA1([ { type: 'range', value: '[foo]bar!R1C' } ], 'B2')) - .toEqual([ { type: 'range', value: '[foo]bar!B$1' } ]); + .toEqual([ { type: 'range', value: "'[foo]bar'!B$1" } ]); testExpr('[Workbook.xlsx]!R1C', 'B2', [ { type: REF_RANGE, value: '[Workbook.xlsx]!B$1' } @@ -246,6 +246,28 @@ describe('translate works with trimmed ranges', () => { }); }); +describe('quote column-letter-like sheet names', () => { + // Excel quotes sheet names that look like column letters when saving + // to XLSX: B!F2:B!F20 is stored as B!F2:'B'!F20. The quotes prevent + // parsers from interpreting "B" as a column letter after ":". + test('single-letter sheet names are quoted', () => { + // Sheet "B" looks like column B + isR2A('=B!R[-1]C[5]:B!R[17]C[5]', 'A2', "='B'!F1:'B'!F19"); + // Sheet "X" looks like column X + isR2A('=X!R1C1', 'A1', "='X'!$A$1"); + }); + + test('multi-letter sheet names matching column patterns are quoted', () => { + // "AA" looks like column AA + isR2A('=AA!R1C1', 'A1', "='AA'!$A$1"); + }); + + test('non-column-like sheet names are not quoted', () => { + isR2A('=Sheet1!R1C1', 'A1', '=Sheet1!$A$1'); + isR2A('=MyData!R1C1', 'A1', '=MyData!$A$1'); + }); +}); + describe('translate r & c as LET parameters', () => { // Unlike in A1, LET(c,1,c) is not valid syntax with the R1C1 notation in Excel. // If you create a cell with this expression in A1 mode and flip to R1C1, Excel From 2a16218c5f358e1740b128ddebcdeec95cd913bd Mon Sep 17 00:00:00 2001 From: Gunnlaugur Thor Briem Date: Sun, 29 Mar 2026 23:07:27 +0000 Subject: [PATCH 3/3] Quote sheet prefix on RHS of range operator in A1 output Excel unconditionally quotes the sheet prefix on the right-hand side of a range operator when saving to XLSX: Sheet1!A1:Sheet1!B2 becomes Sheet1!A1:'Sheet1'!B2. This prevents parsers from misinterpreting the sheet name as a column reference after the colon. Implement the same quoting in translateTokensToA1: after converting R1C1 tokens to A1, any range token that follows a ":" operator and has a sheet prefix (unquoted) gets its prefix quoted. Reverts the earlier reIsColumnLike approach in stringifyPrefixXlsx which incorrectly quoted ALL references to column-like sheet names, not just the RHS of ranges. --- lib/fixRanges.spec.ts | 2 +- lib/stringifyPrefix.ts | 8 +------- lib/translateToA1.spec.ts | 30 +++++++++++++----------------- lib/translateToA1.ts | 25 +++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/lib/fixRanges.spec.ts b/lib/fixRanges.spec.ts index bb33a0b..d1d78fc 100644 --- a/lib/fixRanges.spec.ts +++ b/lib/fixRanges.spec.ts @@ -226,7 +226,7 @@ describe('fixRanges works with xlsx mode', () => { isFixed('=[Workbook]!Table[Column]', '=[Workbook]!Table[Column]', opts); isFixed('=[Lorem Ipsum]!Table[Column]', "='[Lorem Ipsum]'!Table[Column]", opts); isFixed("='[Foo]'!A1", '=[Foo]!A1', opts); - isFixed('=[Foo]Bar!A1', "='[Foo]Bar'!A1", opts); + isFixed('=[Foo]Bar!A1', '=[Foo]Bar!A1', opts); isFixed('=[Foo Bar]Baz!A1', "='[Foo Bar]Baz'!A1", opts); isFixed('=[Foo]!A1', '=[Foo]!A1', opts); isFixed('=[Lorem Ipsum]!A1', "='[Lorem Ipsum]'!A1", opts); diff --git a/lib/stringifyPrefix.ts b/lib/stringifyPrefix.ts index 0b86afc..fb622c5 100644 --- a/lib/stringifyPrefix.ts +++ b/lib/stringifyPrefix.ts @@ -12,9 +12,6 @@ import type { const reBannedChars = /[^0-9A-Za-z._¡¤§¨ª\u00ad¯-\uffff]/; // A1-XFD1048575 | R | C | RC const reIsRangelike = /^(R|C|RC|[A-Z]{1,3}\d{1,7})$/i; -// Bare column letters (A-XFD) — need quoting when used as sheet names -// because they're ambiguous after ":" in ranges like B!F2:B!F20 -const reIsColumnLike = /^[A-Z]{1,3}$/i; export function needQuotes (scope: string, yesItDoes = 0): number { if (yesItDoes) { @@ -69,10 +66,7 @@ export function stringifyPrefixXlsx ( } if (sheetName) { pre += sheetName; - // Quote sheet names that look like column letters (A-XFD) to avoid - // ambiguity in ranges like B!F2:B!F20, where the second B could be - // mistaken for a column. Excel quotes these in XLSX: B!F2:'B'!F20. - quote += needQuotes(sheetName) || (reIsColumnLike.test(sheetName) ? 1 : 0); + quote += needQuotes(sheetName); } if (quote) { pre = quotePrefix(pre); diff --git a/lib/translateToA1.spec.ts b/lib/translateToA1.spec.ts index 8129d16..3870df6 100644 --- a/lib/translateToA1.spec.ts +++ b/lib/translateToA1.spec.ts @@ -211,11 +211,11 @@ describe('translate works with xlsx mode references', () => { ]); expect(translateTokensToA1([ { type: 'range', value: 'foo!R1C' } ], 'B2')) - .toEqual([ { type: 'range', value: "'foo'!B$1" } ]); + .toEqual([ { type: 'range', value: 'foo!B$1' } ]); expect(translateTokensToA1([ { type: 'range', value: '[foo]!R1C' } ], 'B2')) .toEqual([ { type: 'range', value: '[foo]!B$1' } ]); expect(translateTokensToA1([ { type: 'range', value: '[foo]bar!R1C' } ], 'B2')) - .toEqual([ { type: 'range', value: "'[foo]bar'!B$1" } ]); + .toEqual([ { type: 'range', value: '[foo]bar!B$1' } ]); testExpr('[Workbook.xlsx]!R1C', 'B2', [ { type: REF_RANGE, value: '[Workbook.xlsx]!B$1' } @@ -246,25 +246,21 @@ describe('translate works with trimmed ranges', () => { }); }); -describe('quote column-letter-like sheet names', () => { - // Excel quotes sheet names that look like column letters when saving - // to XLSX: B!F2:B!F20 is stored as B!F2:'B'!F20. The quotes prevent - // parsers from interpreting "B" as a column letter after ":". - test('single-letter sheet names are quoted', () => { - // Sheet "B" looks like column B - isR2A('=B!R[-1]C[5]:B!R[17]C[5]', 'A2', "='B'!F1:'B'!F19"); - // Sheet "X" looks like column X - isR2A('=X!R1C1', 'A1', "='X'!$A$1"); +describe('quote sheet prefix on RHS of range operator', () => { + // Excel unconditionally quotes the sheet prefix on the right side of + // a range operator in XLSX: Sheet1!A1:Sheet1!B2 → Sheet1!A1:'Sheet1'!B2. + test('RHS sheet prefix is quoted unconditionally', () => { + isR2A('=B!R[-1]C[5]:B!R[17]C[5]', 'A2', "=B!F1:'B'!F19"); + isR2A('=Sheet1!R1C1:Sheet1!R2C2', 'A1', "=Sheet1!$A$1:'Sheet1'!$B$2"); }); - test('multi-letter sheet names matching column patterns are quoted', () => { - // "AA" looks like column AA - isR2A('=AA!R1C1', 'A1', "='AA'!$A$1"); + test('LHS sheet prefix is not quoted', () => { + isR2A('=B!R1C1', 'A1', '=B!$A$1'); + isR2A('=Sheet1!R1C1', 'A1', '=Sheet1!$A$1'); }); - test('non-column-like sheet names are not quoted', () => { - isR2A('=Sheet1!R1C1', 'A1', '=Sheet1!$A$1'); - isR2A('=MyData!R1C1', 'A1', '=MyData!$A$1'); + test('already-quoted RHS prefix is not double-quoted', () => { + isR2A("='Sheet 1'!R1C1:'Sheet 1'!R2C2", 'A1', "='Sheet 1'!$A$1:'Sheet 1'!$B$2"); }); }); diff --git a/lib/translateToA1.ts b/lib/translateToA1.ts index 0fc41b8..870151f 100644 --- a/lib/translateToA1.ts +++ b/lib/translateToA1.ts @@ -7,6 +7,8 @@ import { parseA1Range } from './parseA1Range.ts'; import type { RangeA1, ReferenceR1C1Xlsx, Token } from './types.ts'; import { stringifyTokens } from './stringifyTokens.ts'; import { cloneToken } from './cloneToken.ts'; +import { OPERATOR } from './constants.ts'; +import { quotePrefix } from './stringifyPrefix.ts'; // Turn on the most permissive setting when parsing ranges so we don't have to think about // this option. We already know that range tokens are legal, so we're not going to encounter @@ -164,6 +166,29 @@ export function translateTokensToA1 ( outTokens[outTokens.length] = token; } + // Excel unconditionally quotes the sheet prefix on the RHS of a range + // operator in XLSX files: Sheet1!A1:Sheet1!B2 → Sheet1!A1:'Sheet1'!B2. + // Apply the same quoting to match Excel's serialization. + for (let i = 2; i < outTokens.length; i++) { + const tok = outTokens[i]; + if (!isRange(tok)) { + continue; + } + const prev = outTokens[i - 1]; + if (prev?.type !== OPERATOR || prev.value !== ':') { + continue; + } + const bangIdx = tok.value.indexOf('!'); + if (bangIdx > 0) { + const prefix = tok.value.slice(0, bangIdx); + // Only quote if not already quoted + if (prefix[0] !== "'") { + outTokens[i] = cloneToken(tok); + outTokens[i].value = quotePrefix(prefix) + tok.value.slice(bangIdx); + } + } + } + return outTokens; }