UNPKG

@borgar/fx

Version:

Utilities for working with Excel formulas

259 lines (249 loc) 8.59 kB
import { MAX_ROWS, MAX_COLS, ERROR } from './constants.ts'; import { stringifyA1RefXlsx } from './stringifyA1Ref.ts'; import { parseR1C1RefXlsx } from './parseR1C1Ref.ts'; import { tokenizeXlsx } from './tokenize.ts'; import { isRange } from './isType.ts'; 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 // ternary ranges who's validity we need to worry about. const REF_OPTS = { allowTernary: true }; function toFixed (val: number, abs: boolean, base: number, max: number, wrapEdges = true) { let v = val; if (v != null && !abs) { v = base + val; // Excel "wraps around" when value goes out of lower bounds. // It's a bit quirky on entry as Excel _really wants_ to re-rewite the // references but the behaviour is consistent with INDIRECT: // ... In A1: RC[-1] => R1C[16383]. if (v < 0) { if (!wrapEdges) { return NaN; } v = max + v + 1; } // ... In B1: =RC[16383] => =RC[-1] if (v > max) { if (!wrapEdges) { return NaN; } v -= max + 1; } } return v; } /** * Options for {@link translateTokensToA1} */ export type OptsTranslateTokensToA1 = { /** * Wrap out-of-bounds ranges around sheet edges rather than turning them to #REF! errors. * @defaultValue true */ wrapEdges?: boolean, }; /** * Translates ranges in a list of tokens from relative R1C1 syntax to absolute A1 syntax. * * ```js * translateToA1("=SUM(RC[1],R2C5,Sheet!R3C5)", "D10"); * // => "=SUM(E10,$E$2,Sheet!$E$3)"); * ``` * * If an input range is -1,-1 relative rows/columns and the anchor is A1, the * resulting range will (by default) wrap around to the bottom of the sheet * resulting in the range XFD1048576. This may not be what you want so may set * `{ wrapEdges }` to false which will instead turn the range into a `#REF!` error. * * ```js * translateToA1("=R[-1]C[-1]", "A1"); * // => "=XFD1048576"); * * translateToA1("=R[-1]C[-1]", "A1", { wrapEdges: false }); * // => "=#REF!"); * ``` * * Note that if you are passing in a list of tokens that was not created using * `mergeRefs` and you disable edge wrapping (or you simply set both options * to false), you can end up with a formula such as `=#REF!:B2` or * `=Sheet3!#REF!:F3`. These are valid formulas in the Excel formula language * and Excel will accept them, but they are not supported in Google Sheets. * * @see {@link OptsTranslateTokensToA1} * @param tokens A token list that should be adjusted. * @param anchorCell A simple string reference to an A1 cell ID (`AF123` or`$C$5`). * @param options Translation options. * @returns A token list. */ export function translateTokensToA1 ( tokens: Token[], anchorCell: string, options: OptsTranslateTokensToA1 = {} ): Token[] { const anchorRange = parseA1Range(anchorCell); if (!anchorRange) { throw new Error('translateToR1C1 got an invalid anchorCell: ' + anchorCell); } const { top, left } = anchorRange; const { wrapEdges = true } = options; let offsetSkew = 0; const outTokens = []; for (let token of tokens) { if (isRange(token)) { token = cloneToken(token); const tokenValue = token.value; // We can get away with using the xlsx ref-parser here because it is more permissive // and we will end up with the same prefix after serialization anyway: const ref = parseR1C1RefXlsx(tokenValue, REF_OPTS) as ReferenceR1C1Xlsx; if (ref) { const d = ref.range; const range: RangeA1 = { top: 0, left: 0 }; const r0 = toFixed(d.r0, d.$r0, top, MAX_ROWS, wrapEdges); const r1 = toFixed(d.r1, d.$r1, top, MAX_ROWS, wrapEdges); if (r0 > r1) { range.top = r1; range.$top = d.$r1; range.bottom = r0; range.$bottom = d.$r0; } else { range.top = r0; range.$top = d.$r0; range.bottom = r1; range.$bottom = d.$r1; } const c0 = toFixed(d.c0, d.$c0, left, MAX_COLS, wrapEdges); const c1 = toFixed(d.c1, d.$c1, left, MAX_COLS, wrapEdges); if (c0 > c1) { range.left = c1; range.$left = d.$c1; range.right = c0; range.$right = d.$c0; } else { range.left = c0; range.$left = d.$c0; range.right = c1; range.$right = d.$c1; } if (d.trim) { range.trim = d.trim; } if (isNaN(r0) || isNaN(r1) || isNaN(c0) || isNaN(c1)) { // convert to ref error token.type = ERROR; token.value = '#REF!'; delete token.groupId; } else { ref.range = range; // @ts-expect-error -- reusing the object, switching it to A1 by swapping the range token.value = stringifyA1RefXlsx(ref); } } // 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; } // 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; } /** * Options for {@link translateFormulaToA1}. */ export type OptsTranslateFormulaToA1 = { /** * Wrap out-of-bounds ranges around sheet edges rather than turning them to #REF! errors. * @defaultValue true */ wrapEdges?: boolean, /** * Should ranges be treated as whole references (`Sheet1!A1:B2`) or as separate tokens * for each part: (`Sheet1`,`!`,`A1`,`:`,`B2`). * @defaultValue true */ mergeRefs?: boolean, /** * 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. * @defaultValue true */ allowTernary?: boolean, }; /** * Translates ranges in a formula from relative R1C1 syntax to absolute A1 syntax. * * ```js * translateToA1("=SUM(RC[1],R2C5,Sheet!R3C5)", "D10"); * // => "=SUM(E10,$E$2,Sheet!$E$3)"); * ``` * * If an input range is -1,-1 relative rows/columns and the anchor is A1, the * resulting range will (by default) wrap around to the bottom of the sheet * resulting in the range XFD1048576. This may not be what you want so you can set * `{ wrapEdges }` to false which will instead turn the range into a `#REF!` error. * * ```js * translateToA1("=R[-1]C[-1]", "A1"); * // => "=XFD1048576"); * * translateToA1("=R[-1]C[-1]", "A1", { wrapEdges: false }); * // => "=#REF!"); * ``` * * @see {@link OptsTranslateFormulaToA1} * @param formula An Excel formula string that should be adjusted. * @param anchorCell A simple string reference to an A1 cell ID (`AF123` or`$C$5`). * @param options Translation options. * @returns A formula string. */ export function translateFormulaToA1 ( formula: string, anchorCell: string, options: OptsTranslateFormulaToA1 = {} ): string { if (typeof formula === 'string') { return stringifyTokens(translateTokensToA1(tokenizeXlsx(formula, { allowTernary: options.allowTernary ?? true, mergeRefs: options.mergeRefs, r1c1: true }), anchorCell, options)); } throw new Error('translateFormulaToA1 expects a formula string'); }