@borgar/fx
Version:
Utilities for working with Excel formulas
173 lines (164 loc) • 5.45 kB
text/typescript
import { stringifyR1C1RefXlsx } from './stringifyR1C1Ref.ts';
import { tokenizeXlsx } from './tokenize.ts';
import { parseA1Range } from './parseA1Range.ts';
import type { RangeR1C1, ReferenceA1Xlsx, Token } from './types.ts';
import { stringifyTokens } from './stringifyTokens.ts';
import { cloneToken } from './cloneToken.ts';
import { FUNCTION, OPERATOR, REF_BEAM, REF_RANGE, REF_TERNARY } from './constants.ts';
import { splitContext } from './parseRef.ts';
import { isRCTokenValue } from './isRCTokenValue.ts';
const reLetLambda = /^l(?:ambda|et)$/i;
const calc = (abs: boolean, vX: number, aX: number): number => {
if (vX == null) {
return null;
}
return abs ? vX : vX - aX;
};
// We already know here that we're holding a token value from
// one of: REF_RANGE | REF_BEAM | REF_TERNARY
// So we can quickly scan for ! shortcut a bunch of parsing:
const unquote = d => d.slice(1, -1).replace(/''/g, "'");
function quickParseA1 (ref: string): ReferenceA1Xlsx {
const split = ref.lastIndexOf('!');
const data: Partial<ReferenceA1Xlsx> = {};
if (split > -1) {
if (ref.startsWith('\'')) {
splitContext(unquote(ref.slice(0, split)), data, true);
}
else {
splitContext(ref.slice(0, split), data, true);
}
data.range = parseA1Range(ref.slice(split + 1));
}
else {
data.range = parseA1Range(ref);
}
return data as ReferenceA1Xlsx;
}
/**
* Options for {@link translateFormulaToR1C1}.
*/
export type OptsTranslateToR1C1 = {
/**
* Enables the recognition of ternary ranges in the style of `A1:A` or `A1:1`.
* These are supported by Google Sheets but not Excel.
* See: [References.md](./References.md).
* @defaultValue true
*/
allowTernary?: boolean,
};
/**
* Translates ranges in a list of tokens from absolute A1 syntax to relative R1C1 syntax.
*
* ```js
* translateFormulaToR1C1("=SUM(E10,$E$2,Sheet!$E$3)", "D10");
* // => "=SUM(RC[1],R2C5,Sheet!R3C5)");
* ```
*
* @param tokens A token list that should be adjusted.
* @param anchorCell A simple string reference to an A1 cell ID (`AF123` or`$C$5`).
* @returns A token list.
*/
export function translateTokensToR1C1 (
tokens: Token[],
anchorCell: string
): Token[] {
const anchorRange = parseA1Range(anchorCell);
if (!anchorRange) {
throw new Error('translateTokensToR1C1 got an invalid anchorCell: ' + anchorCell);
}
const { top, left } = anchorRange;
let withinCall = 0;
let parenDepth = 0;
let offsetSkew = 0;
const outTokens: Token[] = [];
for (let token of tokens) {
const tokenType = token?.type;
if (tokenType === OPERATOR) {
if (token.value === '(') {
parenDepth++;
const lastToken = outTokens[outTokens.length - 1];
if (lastToken && lastToken.type === FUNCTION) {
if (reLetLambda.test(lastToken.value)) {
withinCall = parenDepth;
}
}
}
else if (token.value === ')') {
parenDepth--;
if (parenDepth < withinCall) {
withinCall = 0;
}
}
}
if (tokenType === REF_RANGE || tokenType === REF_BEAM || tokenType === REF_TERNARY) {
token = cloneToken(token);
const tokenValue = token.value;
// We can get away with using the xlsx ref-parser here because it is more permissive:
const ref = quickParseA1(tokenValue);
if (ref) {
const d = ref.range;
const range: RangeR1C1 = {};
range.r0 = calc(d.$top, d.top, top);
range.r1 = calc(d.$bottom, d.bottom, top);
range.c0 = calc(d.$left, d.left, left);
range.c1 = calc(d.$right, d.right, left);
range.$r0 = d.$top;
range.$r1 = d.$bottom;
range.$c0 = d.$left;
range.$c1 = d.$right;
if (d.trim) {
range.trim = d.trim;
}
// @ts-expect-error -- reusing the object, switching it to R1C1 by swapping the range
ref.range = range;
let val = stringifyR1C1RefXlsx(ref);
if (isRCTokenValue(val) && withinCall) {
val += '[0]';
}
token.value = val;
}
// if token includes offsets, those offsets are now likely wrong!
if (token.loc) {
token.loc[0] += offsetSkew;
offsetSkew += token.value.length - tokenValue.length;
token.loc[1] += offsetSkew;
}
}
else if (offsetSkew && token.loc) {
token = cloneToken(token);
token.loc[0] += offsetSkew;
token.loc[1] += offsetSkew;
}
outTokens[outTokens.length] = token;
}
return outTokens;
}
/**
* Translates ranges in a formula from absolute A1 syntax to relative R1C1 syntax.
*
* ```js
* translateFormulaToR1C1("=SUM(E10,$E$2,Sheet!$E$3)", "D10");
* // => "=SUM(RC[1],R2C5,Sheet!R3C5)");
* ```
*
* @see {@link OptsTranslateToR1C1}
* @param formula An Excel formula that should be adjusted.
* @param anchorCell A simple string reference to an A1 cell ID (`AF123` or`$C$5`).
* @param [options={}] The options
* @returns A formula string.
*/
export function translateFormulaToR1C1 (
formula: string,
anchorCell: string,
options: OptsTranslateToR1C1 = {}
): string {
if (typeof formula === 'string') {
const tokens = tokenizeXlsx(formula, {
mergeRefs: false,
allowTernary: options.allowTernary ?? true
});
return stringifyTokens(translateTokensToR1C1(tokens, anchorCell));
}
throw new Error('translateFormulaToA1 expects a formula string');
}