UNPKG

@borgar/fx

Version:

Utilities for working with Excel formulas

240 lines (227 loc) 8.28 kB
import { MAX_ROWS, MAX_COLS, ERROR } from './constants.js'; import { fromA1, parseA1Ref, stringifyA1Ref } from './a1.js'; import { parseR1C1Ref, stringifyR1C1Ref } from './rc.js'; import { tokenize } from './lexer.js'; import { isRange } from './isType.js'; const calc = (abs, vX, aX) => { if (vX == null) { return null; } return abs ? vX : vX - aX; }; const settings = { withLocation: false, mergeRefs: false, allowTernary: true, r1c1: false }; /** * Translates ranges in a formula or list of tokens from absolute A1 syntax to * relative R1C1 syntax. * * Returns the same formula with the ranges translated. If an array of tokens * was supplied, then the same array is returned. * * ```js * translateToR1C1("=SUM(E10,$E$2,Sheet!$E$3)", "D10"); * // => "=SUM(RC[1],R2C5,Sheet!R3C5)"); * ``` * * @param {(string | Array<Token>)} formula A string (an Excel formula) or a token list that should be adjusted. * @param {string} anchorCell A simple string reference to an A1 cell ID (`AF123` or`$C$5`). * @param {object} [options={}] The options * @param {boolean} [options.xlsx=false] Switches to the `[1]Sheet1!A1` or `[1]!name` prefix syntax form for external workbooks. See: [Prefixes.md](./Prefixes.md) * @param {boolean} [options.allowTernary=true] 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. * @returns {(string | Array<Token>)} A formula string or token list (depending on which was input) */ export function translateToR1C1 (formula, anchorCell, { xlsx = false, allowTernary = true } = {}) { const { top, left } = fromA1(anchorCell); const isString = typeof formula === 'string'; const tokens = isString ? tokenize(formula, { ...settings, xlsx, allowTernary }) : formula; let offsetSkew = 0; const refOpts = { xlsx, allowTernary }; tokens.forEach(token => { if (isRange(token)) { const tokenValue = token.value; const ref = parseA1Ref(tokenValue, refOpts); const d = ref.range; const range = {}; 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; } ref.range = range; token.value = stringifyR1C1Ref(ref, refOpts); // 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.loc[0] += offsetSkew; token.loc[1] += offsetSkew; } }); return isString ? tokens.map(d => d.value).join('') : tokens; } function toFixed (val, abs, base, max, 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; } const defaultOptions = { wrapEdges: true, mergeRefs: true, allowTernary: true, xlsx: false }; /** * Translates ranges in a formula or list of tokens from relative R1C1 syntax to * absolute A1 syntax. * * Returns the same formula with the ranges translated. If an array of tokens * was supplied, then the same array is returned. * * ```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. * * @param {(string | Array<Token>)} formula A string (an Excel formula) or a token list that should be adjusted. * @param {string} anchorCell A simple string reference to an A1 cell ID (`AF123` or`$C$5`). * @param {object} [options={}] The options * @param {boolean} [options.wrapEdges=true] Wrap out-of-bounds ranges around sheet edges rather than turning them to #REF! errors * @param {boolean} [options.mergeRefs=true] Should ranges be treated as whole references (`Sheet1!A1:B2`) or as separate tokens for each part: (`Sheet1`,`!`,`A1`,`:`,`B2`). * @param {boolean} [options.xlsx=false] Switches to the `[1]Sheet1!A1` or `[1]!name` prefix syntax form for external workbooks. See: [Prefixes.md](./Prefixes.md) * @param {boolean} [options.allowTernary=true] 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. * @returns {(string | Array<Token>)} A formula string or token list (depending on which was input) */ export function translateToA1 (formula, anchorCell, options = defaultOptions) { const anchor = fromA1(anchorCell); const isString = typeof formula === 'string'; const opts = { ...defaultOptions, ...options }; const tokens = isString ? tokenize(formula, { withLocation: false, mergeRefs: opts.mergeRefs, xlsx: opts.xlsx, allowTernary: opts.allowTernary, r1c1: true }) : formula; let offsetSkew = 0; const refOpts = { xlsx: opts.xlsx, allowTernary: opts.allowTernary }; tokens.forEach(token => { if (isRange(token)) { const tokenValue = token.value; const ref = parseR1C1Ref(tokenValue, refOpts); const d = ref.range; const range = {}; const r0 = toFixed(d.r0, d.$r0, anchor.top, MAX_ROWS, opts.wrapEdges); const r1 = toFixed(d.r1, d.$r1, anchor.top, MAX_ROWS, opts.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, anchor.left, MAX_COLS, opts.wrapEdges); const c1 = toFixed(d.c1, d.$c1, anchor.left, MAX_COLS, opts.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; token.value = stringifyA1Ref(ref, refOpts); } // 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.loc[0] += offsetSkew; token.loc[1] += offsetSkew; } }); return isString ? tokens.map(d => d.value).join('') : tokens; }