UNPKG

@borgar/fx

Version:

Utilities for working with Excel formulas

403 lines (387 loc) 11.5 kB
import { MAX_ROWS, MAX_COLS } from './constants.js'; import { parseRef } from './parseRef.js'; import { stringifyPrefix, stringifyPrefixAlt } from './stringifyPrefix.js'; const clamp = (min, val, max) => Math.min(Math.max(val, min), max); const toColStr = (c, a) => (a ? '$' : '') + toCol(c); const toRowStr = (r, a) => (a ? '$' : '') + toRow(r); const charFrom = String.fromCharCode; /** * Convert a column string representation to a 0 based * offset number (`"C"` = `2`). * * The method expects a valid column identifier made up of _only_ * A-Z letters, which may be either upper or lower case. Other input will * return garbage. * * @param {string} columnString The column string identifier * @returns {number} Zero based column index number */ export function fromCol (columnString) { const x = (columnString || ''); const l = x.length; let n = 0; if (l > 2) { const c = x.charCodeAt(l - 3); const a = c > 95 ? 32 : 0; n += (1 + c - a - 65) * 676; } if (l > 1) { const c = x.charCodeAt(l - 2); const a = c > 95 ? 32 : 0; n += (1 + c - a - 65) * 26; } if (l) { const c = x.charCodeAt(l - 1); const a = c > 95 ? 32 : 0; n += (c - a) - 65; } return n; } /** * Convert a 0 based offset number to a column string * representation (`2` = `"C"`). * * The method expects a number between 0 and 16383. Other input will * return garbage. * * @param {number} columnIndex Zero based column index number * @returns {string} The column string identifier */ export function toCol (columnIndex) { return ( (columnIndex >= 702 ? charFrom(((((columnIndex - 702) / 676) - 0) % 26) + 65) : '') + (columnIndex >= 26 ? charFrom(((((columnIndex / 26) - 1) % 26) + 65)) : '') + charFrom((columnIndex % 26) + 65) ); } export function fromRow (rowStr) { return +rowStr - 1; } export function toRow (top) { return String(top + 1); } export function toRelative (range) { return Object.assign({}, range, { $left: false, $right: false, $top: false, $bottom: false }); } export function toAbsolute (range) { return Object.assign({}, range, { $left: true, $right: true, $top: true, $bottom: true }); } /** * @ignore * @param {'head' | 'tail' | 'both' | null | undefined} trim Does the range have trimming? * @returns {string} The appropriate range join operator */ export function rangeOperator (trim) { if (trim === 'both') { return '.:.'; } else if (trim === 'head') { return '.:'; } else if (trim === 'tail') { return ':.'; } return ':'; } export function trimDirection (head, tail) { if (head && tail) { return 'both'; } if (head) { return 'head'; } if (tail) { return 'tail'; } } /** * Stringify a range object into A1 syntax. * * @private * @ignore * @see parseA1Ref * @param {RangeA1} range A range object * @returns {string} An A1-style string represenation of a range */ export function toA1 (range) { // eslint-disable-next-line prefer-const let { top, left, bottom, right, trim } = range; const { $left, $right, $top, $bottom } = range; const noLeft = left == null; const noRight = right == null; const noTop = top == null; const noBottom = bottom == null; // allow skipping right and bottom to define a cell top = clamp(0, top | 0, MAX_ROWS); left = clamp(0, left | 0, MAX_COLS); if (!noLeft && !noTop && noRight && noBottom) { bottom = top; right = left; } else { bottom = clamp(0, bottom | 0, MAX_ROWS); right = clamp(0, right | 0, MAX_COLS); } const op = rangeOperator(trim); // A:A const allRows = top === 0 && bottom >= MAX_ROWS; const haveAbsCol = ($left && !noLeft) || ($right && !noRight); if ((allRows && !noLeft && !noRight && (!haveAbsCol || left === right)) || (noTop && noBottom)) { return toColStr(left, $left) + op + toColStr(right, $right); } // 1:1 const allCols = left === 0 && right >= MAX_COLS; const haveAbsRow = ($top && !noTop) || ($bottom && !noBottom); if ((allCols && !noTop && !noBottom && (!haveAbsRow || top === bottom)) || (noLeft && noRight)) { return toRowStr(top, $top) + op + toRowStr(bottom, $bottom); } // A1:1 if (!noLeft && !noTop && !noRight && noBottom) { return toColStr(left, $left) + toRowStr(top, $top) + op + toColStr(right, $right); } // A:A1 => A1:1 if (!noLeft && noTop && !noRight && !noBottom) { return toColStr(left, $left) + toRowStr(bottom, $bottom) + op + toColStr(right, $right); } // A1:A if (!noLeft && !noTop && noRight && !noBottom) { return toColStr(left, $left) + toRowStr(top, $top) + op + toRowStr(bottom, $bottom); } // A:A1 => A1:A if (noLeft && !noTop && !noRight && !noBottom) { return toColStr(right, $right) + toRowStr(top, $top) + op + toRowStr(bottom, $bottom); } // A1:A1 if (right !== left || bottom !== top || $right !== $left || $bottom !== $top) { return toColStr(left, $left) + toRowStr(top, $top) + op + toColStr(right, $right) + toRowStr(bottom, $bottom); } // A1 return toColStr(left, $left) + toRowStr(top, $top); } function splitA1 (str) { const m = /^(?=.)(\$(?=\D))?([A-Za-z]{0,3})?(\$)?([1-9][0-9]{0,6})?$/.exec(str); if (!m || (!m[2] && !m[4])) { return null; } return [ m[4] ? fromRow(m[4]) : null, // row index or null m[2] ? fromCol(m[2]) : null, // col index or null !!m[3], // is row absolute? !!m[1] // is col absolute? ]; } /** * Parse a simple string reference to an A1 range into a range object. * Will accept `A1`, `A2`, `A:A`, or `1:1`. * * @private * @ignore * @see parseA1Ref * @param {string} rangeString A range string * @returns {(RangeA1|null)} An object representing a valid range or null if it is invalid. */ export function fromA1 (rangeString) { let top = null; let left = null; let bottom = null; let right = null; let $top = false; let $left = false; let $bottom = false; let $right = false; const [ part1, op1, part2, op2, part3 ] = rangeString.split(/(\.?:\.?)/); if (op2 || part3) { return null; } const trim = trimDirection(!!op1 && op1[0] === '.', !!op1 && op1[op1.length - 1] === '.'); const p1 = splitA1(part1); const p2 = part2 ? splitA1(part2) : null; if (!p1 || (part2 && !p2)) { // invalid section return null; } // part 1 bits if (p1[0] != null && p1[1] != null) { [ top, left, $top, $left ] = p1; } else if (p1[0] == null && p1[1] != null) { [ , left, , $left ] = p1; } else if (p1[0] != null && p1[1] == null) { [ top, , $top ] = p1; } // part 2 bits if (!part2) { // part 2 must exist if either top or left is null: // this disallows a single num or col patterns if (top == null || left == null) { return null; } bottom = top; right = left; $bottom = $top; $right = $left; } else if (p2[0] != null && p2[1] != null) { [ bottom, right, $bottom, $right ] = p2; } else if (p2[0] == null && p2[1] != null) { [ , right, , $right ] = p2; } else if (p2[0] != null && p2[1] == null) { [ bottom, , $bottom ] = p2; } // flip left/right and top/bottom as needed // for partial ranges we perfer the coord on the left-side of the : if (right != null && (left == null || (left != null && right < left))) { [ left, right, $left, $right ] = [ right, left, $right, $left ]; } if (bottom != null && (top == null || (top != null && bottom < top))) { [ top, bottom, $top, $bottom ] = [ bottom, top, $bottom, $top ]; } const r = { top, left, bottom, right, $top, $left, $bottom, $right }; if (trim) { r.trim = trim; } return r; } /** * Parse a string reference into an object representing it. * * ```js * parseA1Ref('Sheet1!A$1:$B2'); * // => { * // context: [ 'Sheet1' ], * // range: { * // top: 0, * // left: 0, * // bottom: 1, * // right: 1 * // $top: true, * // $left: false, * // $bottom: false, * // $right: true * // } * // } * ``` * * For A:A or A1:A style ranges, `null` will be used for any dimensions that the * syntax does not specify: * * @param {string} refString An A1-style reference string * @param {object} [options={}] Options * @param {boolean} [options.allowNamed=true] Enable parsing names as well as ranges. * @param {boolean} [options.allowTernary=false] 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. * @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) * @returns {(ReferenceA1|null)} An object representing a valid reference or null if it is invalid. */ export function parseA1Ref (refString, { allowNamed = true, allowTernary = false, xlsx = false } = {}) { const d = parseRef(refString, { allowNamed, allowTernary, xlsx, r1c1: false }); if (d && (d.r0 || d.name)) { let range = null; if (d.r0) { range = fromA1(d.r1 ? d.r0 + d.operator + d.r1 : d.r0); } if (range) { return xlsx ? { workbookName: d.workbookName, sheetName: d.sheetName, range } : { context: d.context, range }; } if (d.name) { return xlsx ? { workbookName: d.workbookName, sheetName: d.sheetName, name: d.name } : { context: d.context, name: d.name }; } return null; } return null; } /** * Get an A1-style string representation of a reference object. * * ```js * stringifyA1Ref({ * context: [ 'Sheet1' ], * range: { * top: 0, * left: 0, * bottom: 1, * right: 1, * $top: true, * $left: false, * $bottom: false, * $right: true * } * }); * // => 'Sheet1!A$1:$B2' * ``` * * @param {ReferenceA1} refObject A reference object * @param {object} [options={}] 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) * @returns {string} The reference in A1-style string format */ export function stringifyA1Ref (refObject, { xlsx = false } = {}) { const prefix = xlsx ? stringifyPrefixAlt(refObject) : stringifyPrefix(refObject); return prefix + ( refObject.name ? refObject.name : toA1(refObject.range) ); } /** * Fill the any missing bounds in range objects. Top will be set to 0, bottom to * 1048575, left to 0, and right to 16383, if they are `null` or `undefined`. * * ```js * addA1RangeBounds({ * context: [ 'Sheet1' ], * range: { * top: 0, * left: 0, * bottom: 1, * $top: true, * $left: false, * $bottom: false, * } * }); * // => { * // context: [ 'Sheet1' ], * // range: { * // top: 0, * // left: 0, * // bottom: 1, * // right: 16383, * // $top: true, * // $left: false, * // $bottom: false, * // $right: false * // } * // } * ``` * * @param {RangeA1} range The range part of a reference object. * @returns {RangeA1} same range with missing bounds filled in. */ export function addA1RangeBounds (range) { if (range.top == null) { range.top = 0; range.$top = false; } if (range.bottom == null) { range.bottom = MAX_ROWS; range.$bottom = false; } if (range.left == null) { range.left = 0; range.$left = false; } if (range.right == null) { range.right = MAX_COLS; range.$right = false; } return range; }