Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/lexers/canEndRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 !(
Expand All @@ -20,6 +23,7 @@ export function canEndPartialRange (str: string, pos: number): boolean {
(c === 95) || // _
(c === 40) || // (
(c === 36) || // $
(c === 46) // .
(c === 46) || // .
(c === 33) // !
);
}
18 changes: 18 additions & 0 deletions lib/translateToA1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,24 @@ describe('translate works with trimmed ranges', () => {
});
});

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('LHS sheet prefix is not quoted', () => {
isR2A('=B!R1C1', 'A1', '=B!$A$1');
isR2A('=Sheet1!R1C1', 'A1', '=Sheet1!$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");
});
});

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
Expand Down
25 changes: 25 additions & 0 deletions lib/translateToA1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
13 changes: 13 additions & 0 deletions lib/translateToR1C1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down