Skip to content
Closed
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: 6 additions & 0 deletions docs/Prefixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,9 @@ parseA1Ref('[1]!A1', { xlsx: true });
Inversely, when serializing a reference object, _Fx_ expects the `workbookName` and `sheetName` properties to dictate how to compose the prefix.

When the sheet scope is a "3D" sheet range (`Sheet1:Sheet2!A1`) the scope will not be split but emitted as it was found (`{ sheetName: 'Sheet1:Sheet2' }`).

## The right side of a colon

The quotes after a colon decide what the colon means. `foo:'Gamma'!A1` is the name `foo` joined to the reference `'Gamma'!A1`; `Gamma` is quoted even though the name itself needs no quotes, to set it apart from `foo:Gamma!A1`, the 3-D reference over the sheets `foo` to `Gamma`. Excel stores that position quoted whatever the name, `Sheet1!A1:Sheet2!B2` as `Sheet1!A1:'Sheet2'!B2`, even where the left side could not begin a sheet range — a left side that reads as a cell address, `Q1` or `A1`, is that cell rather than a sheet, so there the quotes change no reading and Excel writes them anyway.

The two _Fx_ functions treat it differently. `fixRanges` keeps the quotes a prefix has in that position and adds none, so it never rewrites a reference into one naming different sheets. `translateToA1` quotes every such prefix, as Excel's serialization does.
11 changes: 10 additions & 1 deletion lib/fixRanges.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,23 @@ describe('fixRanges prefixes', () => {
isFixed("='foo':bar!A1", '=foo:bar!A1', { xlsx: true });
});

test('a quote around the second end is the range operator, not a 3D reference', () => {
test('a quote around the RHS makes the colon a range operator, not a sheet span marker', () => {
isFixed("=foo:'bar baz'!A1", "=foo:'bar baz'!A1");
// Excel fixes this to `=foo:'bar baz'!A1`
isFixed("='foo':'bar baz'!A1", "='foo:bar baz'!A1");
isFixed("=foo:'bar baz'!A1", "=foo:'bar baz'!A1", { xlsx: true });
isFixed("=Jan:'[1]Nope'!A1", '=Jan:[1]Nope!A1');
});

test('a quoted RHS keeps quotes its own name would not call for', () => {
// The quote around the right endpoint makes the colon a range operator. So even if the right
// name doesn't need quoting, the quotes are kept, else the meaning would change.
isFixed("=SUM(foo:'Gamma'!A1)", "=SUM(foo:'Gamma'!A1)");
// A cell address on the left side rules out the sheet range interpretation
isFixed("=SUM(Q1:'Sales'!A1)", "=SUM(Q1:'Sales'!A1)");
isFixed("=SUM(A1:'Sales'!B2)", "=SUM(A1:'Sales'!B2)");
});

test('a digit-leading second sheet name goes to the range operator', () => {
isFixed('=SUM(Sheet1:1!A1)', "=SUM('Sheet1:1'!A1)");
isFixed('=SUM(X:1!A1)', "=SUM('X:1'!A1)");
Expand Down
17 changes: 17 additions & 0 deletions lib/fixRanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { REF_STRUCT } from './constants.ts';
import type { ReferenceA1, ReferenceA1Xlsx, Token } from './types.ts';
import { cloneToken } from './cloneToken.ts';
import { stringifyTokens } from './stringifyTokens.ts';
import { followsRangeOperator } from './stringifyPrefix.ts';

// There is no R1C1 counterpart to this. This is because without an anchor cell
// it is impossible to determine if a relative+absolute range (R[1]C[1]:R5C5)
Expand All @@ -31,6 +32,15 @@ export type OptsFixRanges = {
thisRow?: boolean,
};

// Would dropping this operand's quotes let it read as a sheet range? Only for a quoted prefix
// with no workbook bracket: a bracket can only lead a whole prefix, so Jan:'[1]Nope'!A1 is not a
// sheet range either way and its quotes are redundant.
function keepsSourceQuotes (value: string): boolean {
if (!value.startsWith("'")) { return false; }
const bang = value.indexOf('!');
return bang !== -1 && !value.slice(0, bang).includes('[');
}

/**
* Normalizes A1 style ranges and structured references in a list of tokens.
*
Expand Down Expand Up @@ -88,6 +98,13 @@ export function fixTokenRanges (
token.value = newValue;
}
else if (isRange(token)) {
// After a range operator, the quotes on a prefix keep it from reading as part of a sheet
// range, and Excel stores range operations quoted there. Quotes in that position must be
// kept, whether or not the sheet name itself needs them.
if (followsRangeOperator(output, output.length) && keepsSourceQuotes(token.value)) {
output.push(token);
continue;
}
const ref = parseA1Ref(token.value, { allowTernary: true }) as ReferenceA1;
const range = ref.range;
// fill missing dimensions?
Expand Down
18 changes: 17 additions & 1 deletion lib/stringifyPrefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import type {
ReferenceStructXlsx,
ReferenceR1C1Xlsx,
ReferenceName,
ReferenceNameXlsx
ReferenceNameXlsx,
Token
} from './types.ts';
import { OPERATOR, WHITESPACE, NEWLINE } from './constants.ts';

const CHAR_PERIOD = 46;
const CHAR_0 = 48;
Expand All @@ -19,6 +21,20 @@ const reIsRangelike = /^(R\d{0,7}|C\d{0,5}|R\d{0,7}C\d{0,5}|[A-Z]{1,3}\d{1,7})$/
const reIsBoolean = /^(TRUE|FALSE)$/i;
const reIsLinkIndex = /^\d+$/;

const rangeOperators = new Set([ ':', '.:', ':.', '.:.' ]);

// Is the token at `index` the right operand of a range operator? Looks back past any
// whitespace/newline, since Excel allows spacing around the range colon
// (`A1 : Jan:Mar!B2`).
export function followsRangeOperator (tokens: Token[], index: number): boolean {
for (let i = index - 1; i >= 0; i--) {
const t = tokens[i];
if (t.type === WHITESPACE || t.type === NEWLINE) { continue; }
return t.type === OPERATOR && rangeOperators.has(t.value);
}
return false;
}

export function needQuotes (scope: string, blockSheetRanges: boolean, bracketed: boolean, checkColon = false): number {
if (scope) {
if (checkColon && scope.includes(':')) {
Expand Down
21 changes: 21 additions & 0 deletions lib/translateToA1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,27 @@ describe('quote sheet prefix on RHS of range operator', () => {
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");
});

test('a prefix behind whitespace is still the RHS of the range operator', () => {
// Excel allows spacing around the range colon.
isR2A('=B!R1C1 : B!R2C2', 'A1', "=B!$A$1 : 'B'!$B$2");
isR2A('=B!R1C1 :B!R2C2', 'A1', "=B!$A$1 :'B'!$B$2");
});

test('quoting a prefix carries a location skew forward to later tokens', () => {
const tokens = tokenizeXlsx('=SUM(R1C1:Sheet1!R2C2,R9C1)', { r1c1: true, withLocation: true });
expect(translateTokensToA1(tokens, 'A1')).toEqual([
{ type: FX_PREFIX, value: '=', loc: [ 0, 1 ] },
{ type: FUNCTION, value: 'SUM', loc: [ 1, 4 ] },
{ type: OPERATOR, value: '(', loc: [ 4, 5 ] },
{ type: REF_RANGE, value: '$A$1', loc: [ 5, 9 ] },
{ type: OPERATOR, value: ':', loc: [ 9, 10 ] },
{ type: REF_RANGE, value: "'Sheet1'!$B$2", loc: [ 10, 23 ] },
{ type: OPERATOR, value: ',', loc: [ 23, 24 ] },
{ type: REF_RANGE, value: '$A$9', loc: [ 24, 28 ] },
{ type: OPERATOR, value: ')', loc: [ 28, 29 ] }
]);
});
});

describe('translate 3D references', () => {
Expand Down
35 changes: 21 additions & 14 deletions lib/translateToA1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ 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';
import { quotePrefix, followsRangeOperator } 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 @@ -168,24 +167,32 @@ export function translateTokensToA1 (

// 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;
// Apply the same quoting to match Excel's serialization. Quoting lengthens
// the token, so this carries its own skew forward, same as the loop above.
let quoteSkew = 0;
for (let i = 0; i < outTokens.length; i++) {
let tok = outTokens[i];
if (quoteSkew && tok.loc) {
tok = cloneToken(tok);
tok.loc[0] += quoteSkew;
tok.loc[1] += quoteSkew;
outTokens[i] = tok;
}
const prev = outTokens[i - 1];
if (prev?.type !== OPERATOR || prev.value !== ':') {
if (!isRange(tok) || !followsRangeOperator(outTokens, i)) {
continue;
}
const bangIdx = tok.value.indexOf('!');
if (bangIdx > 0) {
if (bangIdx > 0 && tok.value[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);
const oldLength = tok.value.length;
tok = cloneToken(tok);
tok.value = quotePrefix(prefix) + tok.value.slice(bangIdx);
const delta = tok.value.length - oldLength;
if (tok.loc) {
tok.loc[1] += delta;
}
quoteSkew += delta;
outTokens[i] = tok;
}
}

Expand Down