| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011 |
- /**
- * Base spreadsheet calculation error class.
- */
- class CellException extends Error {
- /**
- * Brief symbol to show in the cell to signify an error occurred during
- * evaluation.
- *
- * @type {string}
- */
- errorSymbol;
-
- /**
- * @param {string} message - error message
- * @param {string} errorSymbol - symbol to show in the cell to indicate an error
- */
- constructor(message, errorSymbol='#ERROR') {
- super(message);
- this.errorSymbol = errorSymbol;
- }
- }
-
- /**
- * Exception thrown when a spreadsheet expression is invalid.
- */
- class CellSyntaxException extends CellException {
- constructor(message, errorSymbol='#SYNTAX') {
- super(message, errorSymbol);
- }
- }
-
- /**
- * Exception thrown when a problem occurrs during evaluation.
- */
- class CellEvaluationException extends CellException {}
-
- /**
- * Exception thrown when cell dependencies cannot be resolved.
- */
- class CellDependencyException extends CellException {
- constructor(message, errorSymbol='#REF') {
- super(message, errorSymbol);
- }
- }
-
- /**
- * Expression parsing token types.
- */
- class CellExpressionTokenType {
- static Name = new this('Name');
- static Address = new this('Address');
- static NameOrAddress = new this('NameOrAddress');
- static String = new this('String');
- static Number = new this('Number');
- static OpenParen = new this('OpenParen');
- static CloseParen = new this('CloseParen');
- static Colon = new this('Colon');
- static Plus = new this('Plus');
- static Minus = new this('Minus');
- static Multiply = new this('Multiply');
- static Divide = new this('Divide');
- static Comma = new this('Comma');
- static Semicolon = new this('Semicolon');
- static Ampersand = new this('Ampersand');
- static LessThan = new this('LessThan');
- static LessThanEqual = new this('LessThanEqual');
- static GreaterThan = new this('GreaterThan');
- static GreaterThanEqual = new this('GreaterThanEqual');
- static Equal = new this('Equal');
- static Unequal = new this('Unequal');
- static Not = new this('Not');
-
- /** @type {string} */
- name;
-
- constructor(name) {
- this.name = name;
- }
-
- /**
- * @returns {boolean}
- */
- isPotentialName() {
- return this === CellExpressionTokenType.Name || this === CellExpressionTokenType.NameOrAddress;
- }
-
- /**
- * @returns {boolean}
- */
- isPotentialAddress() {
- return this === CellExpressionTokenType.Address || this === CellExpressionTokenType.NameOrAddress;
- }
-
- toString() {
- return this.name;
- }
-
- equals(other) {
- if (!(other instanceof CellExpressionTokenType)) return false;
- return other.name === this.name;
- }
- }
-
- /**
- * Type of operation for a `CellExpression`. Named functions all fall under
- * `Function`.
- */
- class CellExpressionOperation {
- /** Arg is `number` */
- static Number = new this('Number');
- /** Arg is `string` without quotes */
- static String = new this('String');
- /** Arg is `boolean` */
- static Boolean = new this('Boolean');
- /** Arg is `CellAddress` */
- static Reference = new this('Reference');
- /** Args are start and end `CellAddress`es (e.g. "A5", "C7") */
- static Range = new this('Range');
-
- /** Args are two operand `CellExpression`s. */
- static Add = new this('Add');
- /** Args are two operand `CellExpression`s */
- static Subtract = new this('Subtract');
- /** Args are two operand `CellExpression`s */
- static Multiply = new this('Multiply');
- /** Args are two operand `CellExpression`s */
- static Divide = new this('Divide');
-
- /** Args are two operand `CellExpression`s. */
- static Concatenate = new this('Concatenate');
-
- /** Arg is operand `CellExpression` */
- static UnaryMinus = new this('UnaryMinus');
-
- /** Args are two operand `CellExpression`s. */
- static GreaterThan = new this('GreaterThan');
- /** Args are two operand `CellExpression`s. */
- static GreaterThanEqual = new this('GreaterThanEqual');
- /** Args are two operand `CellExpression`s. */
- static LessThan = new this('LessThan');
- /** Args are two operand `CellExpression`s. */
- static LessThanEqual = new this('LessThanEqual');
- /** Args are two operand `CellExpression`s. */
- static Equal = new this('Equal');
- /** Args are two operand `CellExpression`s. */
- static Unequal = new this('Unequal');
-
- /** Arg is operand `CellExpression`. */
- static UnaryNot = new this('UnaryNot');
-
- /** Args are 0+ `CellExpression`s */
- static Function = new this('Function');
-
- /** @type {string} */
- name;
-
- constructor(name) {
- this.name = name;
- }
-
- toString() {
- return `${this.constructor.name}.${this.name}`;
- }
-
- equals(other) {
- if (!(other instanceof CellExpressionOperation)) return false;
- return other.name == this.name;
- }
- }
-
- /**
- * Collection of all calculated cells in a table. Evaluates formulas.
- */
- class CellExpressionSet {
- /** @type {SpreadsheetGrid} */
- #grid;
- /**
- * @param {SpreadsheetGrid} grid
- */
- constructor(grid) {
- this.#grid = grid;
- }
-
- /**
- * Populates the `outputValue` fields of every cell in the table. Cells
- * with formulas will attempt to be calculated or populated with error
- * values.
- */
- calculateCells() {
- const rowCount = this.#grid.rowCount;
- const colCount = this.#grid.columnCount;
-
- // Make queue of cell addresses with expressions in them
- /** @type {CellAddress[]} */
- var expressionAddressQueue = [];
- var range = new CellAddressRange(new CellAddress(0, 0), new CellAddress(colCount - 1, rowCount - 1));
- for (const address of range.cellsIn(this.#grid)) {
- const cell = this.#grid.cellAt(address);
- const value = cell.originalValue;
- if (value.type != CellValue.TYPE_FORMULA) {
- cell.outputValue = value;
- cell.isCalculated = false;
- continue;
- }
- try {
- const expression = CellExpression.parse(value.formattedValue, address);
- if (!expression) {
- throw new CellSyntaxException("Invalid expression");
- }
- cell.parsedExpression = expression;
- cell.isCalculated = true;
- expressionAddressQueue.push(address);
- this.#enqueueFilledBlanks(expression, expressionAddressQueue);
- } catch (e) {
- if (e instanceof CellSyntaxException || e instanceof CellEvaluationException) {
- cell.outputValue = CellValue.fromValue(e);
- } else {
- throw e;
- }
- }
- }
-
- // Try to evaluate each cell. If one depends on cells not yet calculated,
- // move it to the back of the queue and try again later.
- this.#processExpressionQueue(expressionAddressQueue);
- // Anything left in the queue is a circular reference.
- this.#processCircularReferences(expressionAddressQueue);
- }
-
- /**
- * Attempts to evaluate expressions at the given `addresses`. If an
- * expression has unevaluated references, the expression is moved to the
- * end of the queue and tried again later. When this method returns, any
- * elements left in `addresses` can be considered circular references.
- *
- * @param {CellAddress[]} addresses - mutable queue of formula addresses
- */
- #processExpressionQueue(addresses) {
- var requeueCount = 0;
- while (addresses.length > 0 && requeueCount < addresses.length) {
- const address = addresses[0];
- addresses.splice(0, 1);
- const cell = this.#grid.cellAt(address);
- try {
- const result = this.#evaluate(cell.parsedExpression, address);
- cell.isCalculated = true;
- if (result instanceof CellValue) {
- cell.outputValue = result;
- requeueCount = 0;
- } else if (Array.isArray(result)) {
- if (result.length == 1) {
- cell.outputValue = result[0];
- requeueCount = 0;
- } else {
- throw new CellEvaluationException(`Expression resolved to ${result.length} values, single value expected`);
- }
- } else {
- throw new CellEvaluationException(`Expression resolved to ${result && result.constructor ? result.constructor.name : typeof result}, expected CellValue`);
- }
- } catch (e) {
- if (e instanceof CellDependencyException) {
- // Depends on a value that hasn't been calculated yet
- addresses.push(address);
- requeueCount++;
- } else if (e instanceof CellSyntaxException || e instanceof CellEvaluationException) {
- cell.outputValue = CellValue.fromValue(e);
- requeueCount = 0;
- } else {
- throw e;
- }
- }
- }
- }
-
- /**
- * Autofills a formula, transposing the formula to each affected cell and
- * stored in `parsedExpression`, and each address is queued in `addresses`
- * for evaluation.
- *
- * @param {CellExpression} expression - autofilled formula
- * @param {CellAddress[]} addresses - mutable address queue
- */
- #enqueueFilledBlanks(expression, addresses) {
- for (const range of expression.fillRanges ?? []) {
- for (const filledAddress of range.cellsIn(this.#grid)) {
- const filledCell = this.#grid.cellAt(filledAddress);
- if (filledCell.originalValue.type == CellValue.TYPE_BLANK &&
- (!filledCell.outputValue ||
- filledCell.outputValue.type == CellValue.TYPE_BLANK)) {
- filledCell.parsedExpression = expression.transpose(expression.location, filledAddress);
- filledCell.isCalculated = true;
- addresses.push(filledAddress);
- }
- }
- }
- }
-
- /**
- * Marks all cells at the given addresses as circular references.
- *
- * @param {CellAddress[]} addresses - mutable address queue
- */
- #processCircularReferences(addresses) {
- for (const address of addresses) {
- const cell = this.#grid.cellAt(address);
- cell.outputValue = CellValue.fromValue(new CellDependencyException(`Circular reference at ${address.name}`));
- }
- }
-
- /**
- * Evaluates an expression if possible.
- *
- * @param {CellExpression} expr - expression to evaluate
- * @param {CellAddress} address - location of expression
- * @returns {CellValue|CellValue[]} - results
- */
- #evaluate(expr, address) {
- const result = this.#preevaluate(expr, address);
- if (result instanceof CellValue) {
- // Expression included formatting override. Apply it to value.
- if (expr.outputType !== null) {
- return CellValue.fromValue(result.value, expr.outputType ?? result.type, expr.outputDecimals);
- }
- }
- return result;
- }
-
- /**
- * Evaluates an expression if possible. No custom formatting is applied to
- * result.
- *
- * @param {CellExpression} expr - expression to evaluate
- * @param {CellAddress} address - location of expression
- * @returns {CellValue|CellValue[]} - results
- * @throws {CellException} if evaluation fails for any reason
- */
- #preevaluate(expr, address) {
- switch (expr.op) {
- case CellExpressionOperation.Number:
- case CellExpressionOperation.String:
- case CellExpressionOperation.Boolean:
- return expr.arguments[0];
-
- case CellExpressionOperation.Reference: {
- const refAddress = expr.arguments[0];
- const cell = this.#grid.cellAt(refAddress);
- if (cell === null) {
- throw new CellEvaluationException(`No cell at ${refAddress.name}`, '#REF');
- }
- if (cell.outputValue === null) {
- throw new CellDependencyException(`Need calculated value for ${refAddress} to evaluate`);
- }
- return cell.outputValue;
- }
- case CellExpressionOperation.Range: {
- const range = expr.arguments[0];
- var values = [];
- for (const rAddress of range.cellsIn(this.#grid)) {
- const cell = this.#grid.cellAt(rAddress);
- if (rAddress.equals(address)) continue;
- const val = this.#grid.outputValueAt(rAddress);
- if (val === null) {
- throw new CellDependencyException(`Need calculated value for ${rAddress.name} to evaluate`);
- }
- values.push(val);
- }
- return values;
- }
-
- case CellExpressionOperation.Add: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.add(op2);
- }
- case CellExpressionOperation.Subtract: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.subtract(op2);
- }
- case CellExpressionOperation.Multiply: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.multiply(op2);
- }
- case CellExpressionOperation.Divide: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.divide(op2);
- }
- case CellExpressionOperation.UnaryMinus: {
- const op = this.#evaluate(expr.arguments[0], address);
- return CellValue.fromValue(0).subtract(op);
- }
-
- case CellExpressionOperation.GreaterThan: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.gt(op2);
- }
- case CellExpressionOperation.GreaterThanEqual: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.gte(op2);
- }
- case CellExpressionOperation.LessThan: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.lt(op2);
- }
- case CellExpressionOperation.LessThanEqual: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.lte(op2);
- }
- case CellExpressionOperation.Equal: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.eq(op2);
- }
- case CellExpressionOperation.Unequal: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.neq(op2);
- }
-
- case CellExpressionOperation.UnaryNot: {
- const op = this.#evaluate(expr.arguments[0], address);
- return op.not();
- }
-
- case CellExpressionOperation.Concatenate: {
- const op1 = this.#evaluate(expr.arguments[0], address);
- const op2 = this.#evaluate(expr.arguments[1], address);
- return op1.concatenate(op2);
- }
-
- case CellExpressionOperation.Function:
- return this.#callFunction(expr.qualifier, expr.arguments, address);
- }
- throw new CellSyntaxException(`Unhandled operation ${expr.op.name}`);
- }
-
- /**
- * Evaluates a named function, e.g. `ABS`, `SUM`, etc.
- *
- * @param {string} functionName - function name
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - location of the expression
- * @returns {CellValue} - result
- * @throws {CellException} if evaluation fails for any reason
- */
- #callFunction(functionName, args, address) {
- switch (functionName.toUpperCase()) {
- case 'ABS': return this.#funcAbs(args, address);
- case 'AND': return this.#funcAnd(args, address);
- case 'AVERAGE': return this.#funcAverage(args, address);
- case 'CEILING': return this.#funcCeiling(args, address);
- case 'EXP': return this.#funcExp(args, address);
- case 'FLOOR': return this.#funcFloor(args, address);
- case 'IF': return this.#funcIf(args, address);
- case 'IFS': return this.#funcIfs(args, address);
- case 'LN': return this.#funcLn(args, address);
- case 'LOG': return this.#funcLog(args, address);
- case 'LOWER': return this.#funcLower(args, address);
- case 'MAX': return this.#funcMax(args, address);
- case 'MIN': return this.#funcMin(args, address);
- case 'MOD': return this.#funcMod(args, address);
- case 'NOT': return this.#funcNot(args, address);
- case 'OR': return this.#funcOr(args, address);
- case 'POWER': return this.#funcPower(args, address);
- case 'ROUND': return this.#funcRound(args, address);
- case 'SQRT': return this.#funcSqrt(args, address);
- case 'SUBSTITUTE': return this.#funcSubstitute(args, address);
- case 'SUM': return this.#funcSum(args, address);
- case 'UPPER': return this.#funcUpper(args, address);
- case 'XOR': return this.#funcXor(args, address);
- default:
- throw new CellSyntaxException(`Unknown function "${functionName}"`);
- }
- }
-
- /**
- * Checks and evaluates arguments for a numeric function. If successful,
- * an array of `CellValue`s with numeric values is returned.
- *
- * @param {string} functionName - name of the function (for debugging)
- * @param {number} minArgs - minimum required arguments
- * @param {number} maxArgs - maximum required arguments
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - address of the formula
- * @returns {CellValue[]} numeric arguments
- * @throws {CellSyntaxException} if wrong number of arguments is passed
- * @throws {CellEvaluationException} if an argument does not resolve to a numeric value
- */
- #assertNumericArguments(functionName, minArgs, maxArgs, args, address) {
- const argCount = args.length;
- if (argCount < minArgs || argCount > maxArgs) {
- if (minArgs == maxArgs) {
- throw new CellSyntaxException(`${functionName}() expects ${minArgs} arguments, got ${argCount}`);
- }
- throw new CellSyntaxException(`${functionName}() expects between ${minArgs} and ${maxArgs} arguments, got ${argCount}`);
- }
- var out = [];
- for (const argument of args) {
- const evaled = this.#evaluate(argument, address);
- if (!(evaled instanceof CellValue) || !evaled.isNumeric()) {
- throw new CellEvaluationException(`${functionName}() expects numeric arguments`);
- }
- out.push(evaled);
- }
- return out;
- }
-
- /**
- * Evaluates and flattens numeric arguments. For functions that can take
- * arbitrary numbers of values including whole cell ranges.
- *
- * @param {string} functionName - function name (for debugging)
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - location of expression
- * @param {boolean} errorOnNonnumeric - whether to throw an exception if
- * non-numeric arguments are encountered, otherwise they're skipped silently
- * @returns {CellValue[]} flattened array of numeric values
- */
- #flattenedNumericArguments(functionName, args, address, errorOnNonnumeric=true) {
- var flattened = [];
- for (const argument of args) {
- const evaled = this.#evaluate(argument, address);
- if (evaled instanceof CellValue) {
- if (!evaled.isNumeric()) {
- if (errorOnNonnumeric) {
- throw new CellEvaluationException(`${functionName} requires numeric arguments`);
- }
- continue;
- }
- flattened.push(evaled);
- } else if (Array.isArray(evaled)) {
- const arr = evaled;
- for (const arrayArgument of arr) {
- if (arrayArgument instanceof CellValue) {
- if (!arrayArgument.isNumeric()) {
- if (errorOnNonnumeric) {
- throw new CellEvaluationException(`${functionName} requires numeric arguments`);
- }
- continue;
- }
- flattened.push(arrayArgument);
- }
- }
- }
- }
- return flattened;
- }
-
- /**
- * `ABS(value)` - absolute value of a numeric argument
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcAbs(args, address) {
- const arg = this.#assertNumericArguments('ABS', 1, 1, args, address)[0];
- if (arg.value < 0.0) {
- return CellValue.fromValue(0).subtract(arg);
- }
- return arg;
- }
-
- /**
- * `AND(arg, arg, ..., arg)` - Boolean AND of any number of Boolean arguments
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcAnd(args, address) {
- if (args.length == 0) {
- throw new CellEvaluationException("AND requires one or more arguments");
- }
- const values = this.#flattenedNumericArguments('AND', args, address, false);
- for (const value of values) {
- const result = value.booleanValue();
- if (result === null) {
- throw new CellEvaluationException("AND requires boolean arguments");
- }
- if (!result) return CellValue.fromValue(false);
- }
- return CellValue.fromValue(true);
- }
-
- /**
- * `AVERAGE(arg, arg, ..., arg)` - Averages values. Non-numeric values are
- * omitted from the calculation.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcAverage(args, address) {
- var sum = CellValue.fromValue(0);
- var count = 0;
- for (const arg of args) {
- const val = this.#evaluate(arg, address);
- if (Array.isArray(val)) {
- for (const elem of val) {
- if (!elem.isNumeric()) continue;
- sum = sum.add(elem);
- count++;
- }
- } else if (val.isNumeric()) {
- sum = sum.add(val);
- count++;
- }
- }
- return (count > 0) ? sum.divide(CellValue.fromValue(count)) : CellValue.fromValue(0);
- }
-
- /**
- * `CEILING(value)` - Ceiling of a numeric value.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcCeiling(args, address) {
- const arg = this.#assertNumericArguments('CEILING', 1, 1, args, address)[0];
- const newValue = Math.ceil(arg.value);
- return CellValue.fromValue(newValue, arg.type);
- }
-
- /**
- * `EXP(value)` - Computes _e_ raised to the given exponent.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcExp(args, address) {
- const arg = this.#assertNumericArguments('EXP', 1, 1, args, address)[0];
- const newValue = Math.exp(arg.value);
- return CellValue.fromValue(newValue, arg.type);
- }
-
- /**
- * `FLOOR(value)` - Numeric value rounded down.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcFloor(args, address) {
- const arg = this.#assertNumericArguments('FLOOR', 1, 1, args, address)[0];
- const newValue = Math.floor(arg.value);
- return CellValue.fromValue(newValue, arg.type);
- }
-
- /**
- * `IF(test, trueVal, falseVal)` - Conditional test.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcIf(args, address) {
- if (args.length != 3) {
- throw new CellEvaluationException("IF expects three arguments");
- }
- const evaled = args.map((arg) => this.#evaluate(arg, address));
- const test = evaled[0].booleanValue();
- if (test === null) {
- throw new CellEvaluationException("IF expects a boolean for the first argument");
- }
- if (test) {
- return evaled[1];
- } else {
- return evaled[2];
- }
- }
-
- /**
- * `IFS(test1, result1, test2, result2, ..., fallbackResult)` - Multiple if
- * statement. Takes 3 or more arguments of an odd number consisting of pairs
- * of a Boolean test followed by the value to return if true. The very last
- * value is the fallback value to return if none of the Boolean tests are true.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcIfs(args, address) {
- if (args.length < 3) {
- throw new CellEvaluationException("IFS expects at least 3 arguments");
- }
- if ((args.length % 2) != 1) {
- throw new CellEvaluationException("IFS expects an odd number of arguments");
- }
- const evaled = args.map((arg) => this.#evaluate(arg, address));
- for (var i = 0; i < evaled.length - 1; i += 2) {
- const test = evaled[i].booleanValue();
- if (test === null) {
- throw new CellEvaluationException(`IFS expects a boolean for argument ${i + 1}`);
- }
- if (test) {
- return evaled[i + 1];
- }
- }
- return evaled[evaled.length - 1];
- }
-
- /**
- * `LN(value)` - Natural log.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcLn(args, address) {
- const arg = this.#assertNumericArguments('LN', 1, 1, args, address)[0];
- const newValue = Math.log(arg.value);
- return CellValue.fromValue(newValue, arg.type);
- }
-
- /**
- * `LOG(value, [base])` - Logarithm. Base is 10 if not provided.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcLog(args, address) {
- const evaled = this.#assertNumericArguments('LOG', 1, 2, args, address);
- const exponent = evaled[0];
- const base = (evaled.length > 1) ? evaled[1].value : 10.0;
- const newValue = Math.log(exponent.value) / Math.log(base);
- return CellValue.fromValue(newValue, exponent.type);
- }
-
- /**
- * `LOWER(text)` - Lowercase version of a string.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcLower(args, address) {
- if (args.length != 1) {
- throw new CellEvaluationException("LOWER requires one argument");
- }
- const evaled = args.map((arg) => this.#evaluate(arg, address));
- const s = evaled[0].stringValue(true);
- if (s === null) {
- throw new CellEvaluationException("LOWER requires one string argument");
- }
- return CellValue.fromValue(s.toLowerCase());
- }
-
- /**
- * `MAX(arg, arg, ... arg)` - Returns the maximum numeric value.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcMax(args, address) {
- var maxValue = null;
- const flattened = this.#flattenedNumericArguments('MAX', args, address);
- if (flattened.length == 0) {
- throw new CellEvaluationException("MAX requires at least one numeric argument");
- }
- for (const argument of flattened) {
- if (maxValue === null || argument.value > maxValue.value) {
- maxValue = argument;
- }
- }
- return maxValue ?? CellValue.fromValue(0);
- }
-
- /**
- * `MIN(arg, arg, ... arg)` - Returns the minimum numeric value.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcMin(args, address) {
- var minValue = null;
- const flattened = this.#flattenedNumericArguments('MIN', args, address);
- if (flattened.length == 0) {
- throw new CellEvaluationException("MIN requires at least one numeric argument");
- }
- for (const argument of flattened) {
- if (minValue === null || argument.value < minValue.value) {
- minValue = argument;
- }
- }
- return minValue ?? CellValue.fromValue(0);
- }
-
- /**
- * `MOD(value, divisor)` - Returns the remainder after a division.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcMod(args, address) {
- if (args.length != 2) {
- throw new CellEvaluationException("MOD requires two numeric arguments");
- }
- const values = args.map((arg) => this.#evaluate(arg, address));
- return values[0].modulo(values[1]);
- }
-
- /**
- * `NOT(value)` - Boolean NOT of a Boolean value.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcNot(args, address) {
- if (args.length != 1) {
- throw new CellEvaluationException("NOT expects one argument");
- }
- const evaled = args.map((arg) => this.#evaluate(arg, address));
- const b = evaled[0].booleanValue();
- if (b === null) {
- throw new CellEvaluationException("NOT expects boolean argument");
- }
- return CellValue.fromValue(!b);
- }
-
- /**
- * `OR(arg, arg, ... arg)` - Boolean OR of one or more Boolean arguments.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcOr(args, address) {
- if (args.length == 0) {
- throw new CellEvaluationException("OR requires one or more arguments");
- }
- const values = this.#flattenedNumericArguments('OR', args, address, false);
- for (const value of values) {
- const result = value.booleanValue();
- if (result === null) {
- throw new CellEvaluationException("OR requires boolean arguments");
- }
- if (result) return CellValue.fromValue(true);
- }
- return CellValue.fromValue(false);
- }
-
- /**
- * `POWER(base, exponent)` - Raises a base to an exponent.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcPower(args, address) {
- const evaled = this.#assertNumericArguments('POWER', 2, 2, args, address);
- const base = evaled[0];
- const exp = evaled[1];
- const val = Math.pow(base.value, exp.value);
- return CellValue.fromValue(val, base.type);
- }
-
- /**
- * `ROUND(value, [places])` - Rounds a number, optionally to the given number
- * of digits after the decimal place. Negative places round to the nearest
- * 10, 100, 1000, etc.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcRound(args, address) {
- const evaled = this.#assertNumericArguments('ROUND', 1, 2, args, address);
- const val = evaled[0];
- const places = evaled.length > 1 ? evaled[1].value : 0;
- const divider = Math.pow(10.0, places);
- const newValue = Math.round(val.value * divider) / divider;
- return CellValue.fromValue(newValue, val.type);
- }
-
- /**
- * `SQRT(value)` - Square root.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcSqrt(args, address) {
- const arg = this.#assertNumericArguments('SQRT', 1, 1, args, address)[0];
- const val = arg.numericValue();
- return CellValue.fromValue(Math.sqrt(val));
- }
-
- /**
- * `SUBSTITUTE(text, pattern, replacement)` - Substitutes all occurrences of
- * `pattern` within `text` with `replacement`.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcSubstitute(args, address) {
- if (args.length != 3) {
- throw new CellEvaluationException("SUBSTITUTE expects 3 string arguments");
- }
- const values = args.map((arg) => this.#evaluate(arg, address));
- const text = values[0].stringValue();
- const search = values[1].stringValue();
- const replace = values[2].stringValue();
- if (text === null || search === null || replace === null) {
- throw new CellEvaluationException("SUBSTITUTE expects 3 string arguments");
- }
- const result = text.replace(new RegExp(MDUtils.escapeRegex(search), 'gi'), replace);
- return CellValue.fromValue(result);
- }
-
- /**
- * `SUM(arg, arg, ... arg)` - Calculates the sum of numeric arguments.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcSum(args, address) {
- var sum = CellValue.fromValue(0);
- for (const arg of args) {
- const val = this.#evaluate(arg, address);
- if (Array.isArray(val)) {
- for (const elem of val) {
- if (elem.isNumeric()) sum = sum.add(elem);
- }
- } else if (val.isNumeric()) {
- sum = sum.add(val);
- }
- }
- return sum;
- }
-
- /**
- * `UPPER(text)` - Uppercase of a text argument.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcUpper(args, address) {
- if (args.length != 1) {
- throw new CellEvaluationException("UPPER requires one argument");
- }
- const evaled = args.map((arg) => this.#evaluate(arg, address));
- const s = evaled[0].stringValue(true);
- if (s === null) {
- throw new CellEvaluationException("UPPER requires one string argument");
- }
- return CellValue.fromValue(s.toUpperCase());
- }
-
- /**
- * `XOR(arg, arg, ... arg)` - Boolean XOR of one or more Boolean arguments.
- *
- * @param {Array} args - raw arguments
- * @param {CellAddress} address - expression location
- * @returns {CellValue} - result
- */
- #funcXor(args, address) {
- if (args.length == 0) {
- throw new CellEvaluationException("XOR requires one or more arguments");
- }
- const values = this.#flattenedNumericArguments('XOR', args, address, false);
- var result = null;
- for (const value of values) {
- const b = value.booleanValue();
- if (b === null) {
- throw new CellEvaluationException("XOR requires boolean arguments");
- }
- result = (result === null) ? b : (result ^ b);
- }
- return CellValue.fromValue(result != 0);
- }
- }
-
- /**
- * A spreadsheet formula expression. Evaluation is done by `CellExpressionSet`.
- */
- class CellExpression {
- /**
- * Operation.
- * @type {CellExpressionOperation}
- */
- op;
-
- /**
- * Mixed array of `CellValue`, `CellAddress`, `CellAddressRange`,
- * `CellExpression`, perhaps more.
- * @type {Array}
- */
- arguments;
-
- /**
- * For `Function`, the function name.
- * @type {string|null}
- */
- qualifier;
-
- /**
- * Optional format override. One of `number`, `currency`, `percent`.
- * @type {string|null}
- */
- outputType = null;
-
- /**
- * Optional decimal place formatting override.
- * @type {number|null}
- */
- outputDecimals = null;
-
- /**
- * Address ranges to copy this expression into for any blank cells. Used
- * by formulas with the `FILL` modifier.
- * @type {CellAddressRange[]|null} fillRanges
- */
- fillRanges = null;
-
- /**
- * Source address of the formula.
- * @type {CellAddress|null}
- */
- location = null;
-
- /**
- * @param {CellExpressionOperation} op
- * @param {Array} args
- * @param {string|null} qualifier
- */
- constructor(op, args, qualifier = null) {
- this.op = op;
- this.arguments = args;
- this.qualifier = qualifier;
- }
-
- /**
- * Attempts to parse a formula expression.
- *
- * @param {string} expression - formula string, including leading `=`
- * @param {CellAddress} address - location of the formula
- * @returns {CellExpression|null} - parsed expression, or `null` if it failed
- */
- static parse(expression, address) {
- const tokens = this.expressionToTokens(expression);
- if (tokens.length == 0) return null;
- const expr = this.expressionFromTokens(tokens, address);
- expr.location = address;
- return expr;
- }
-
- /**
- * Writes an expression tree to console.error for debugging purposes.
- *
- * @param {CellExpression} expression
- * @param {string} indent
- */
- static dumpExpression(expression, indent = '') {
- if (expression.arguments.length == 0) {
- console.error(indent + "expr " + expression.op.name + '()');
- } else {
- console.error(indent + expression.op.name + '(');
- for (const argument of expression.arguments) {
- if (typeof argument == 'number') {
- console.error(indent + `\t${argument}`);
- } else if (typeof argument == 'string') {
- console.error(indent + `\t"${argument}"`);
- } else if (typeof argument == 'boolean') {
- console.error(indent + "\t" + (argument ? "true" : "false"));
- } else if (argument instanceof CellAddress) {
- console.error(indent + "\t" + argument.name);
- } else if (argument instanceof CellAddressRange) {
- console.error(indent + "\t" + argument.name);
- } else if (argument instanceof CellValue) {
- console.error(indent + "\t" + argument.type + " " + argument.formattedValue);
- } else if (argument instanceof CellExpression) {
- this.dumpExpression(argument, indent + "\t");
- } else {
- console.error(indent + "\t" + typeof argument);
- }
- }
- console.error(indent + ')');
- }
- }
-
- #clone() {
- const cp = new CellExpression();
- cp.op = this.op;
- cp.arguments = this.arguments.slice();
- cp.qualifier = this.qualifier;
- cp.outputType = this.outputType;
- cp.outputDecimals = this.outputDecimals;
- cp.fillRanges = this.fillRanges !== null ? this.fillRanges.slice() : null;
- cp.location = this.location;
- return cp;
- }
-
- /**
- * Returns a copy of this expression with cell references transposed by
- * the delta between `start` and `end` addresses. Used for repeating an
- * autofilled formula into blank cells.
- *
- * @param {CellAddress} start
- * @param {CellAddress} end
- * @returns {CellExpression|null}
- */
- transpose(start, end) {
- var transposed = this.#clone(); // structuredClone makes a mess of typing
- transposed.arguments = [];
- for (const argument of this.arguments) {
- if (argument instanceof CellExpression) {
- transposed.arguments.push(argument.transpose(start, end));
- } else if (argument instanceof CellAddress) {
- transposed.arguments.push(argument.transpose(start, end));
- } else if (argument instanceof CellAddressRange) {
- transposed.arguments.push(argument.transpose(start, end));
- } else {
- transposed.arguments.push(argument);
- }
- }
- return transposed;
- }
-
- // -- Tokenizing --------------------------------------------------------
-
- /**
- * Converts an expression into an array of tokens.
- * @param {string} text - expression
- * @returns {CellExpressionToken[]} tokens
- */
- static expressionToTokens(text) {
- var tokens = [];
- var pos = [0];
- this.#skipWhitespace(text, pos);
- if (text.substring(pos[0], pos[0] + 1) == '=') {
- // Ignore equals
- pos[0]++;
- }
- this.#skipWhitespace(text, pos);
- var l = text.length;
- while (pos[0] < l) {
- tokens.push(this.#readNextToken(text, pos));
- this.#skipWhitespace(text, pos);
- }
- return tokens;
- }
-
- /**
- * @param {string} text
- * @param {number[]} pos
- * @returns {CellExpressionToken}
- */
- static #readNextToken(text, pos) {
- // Single char tokens
- var token;
- if (token = this.#readNextSimpleToken(text, pos, '==', CellExpressionTokenType.Equal)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '!=', CellExpressionTokenType.Unequal)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '<=', CellExpressionTokenType.LessThanEqual)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '>=', CellExpressionTokenType.GreaterThanEqual)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '<', CellExpressionTokenType.LessThan)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '>', CellExpressionTokenType.GreaterThan)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '!', CellExpressionTokenType.Not)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '+', CellExpressionTokenType.Plus)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '-', CellExpressionTokenType.Minus)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '*', CellExpressionTokenType.Multiply)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '/', CellExpressionTokenType.Divide)) return token;
- if (token = this.#readNextSimpleToken(text, pos, ',', CellExpressionTokenType.Comma)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '(', CellExpressionTokenType.OpenParen)) return token;
- if (token = this.#readNextSimpleToken(text, pos, ')', CellExpressionTokenType.CloseParen)) return token;
- if (token = this.#readNextSimpleToken(text, pos, ':', CellExpressionTokenType.Colon)) return token;
- if (token = this.#readNextSimpleToken(text, pos, ';', CellExpressionTokenType.Semicolon)) return token;
- if (token = this.#readNextSimpleToken(text, pos, '&', CellExpressionTokenType.Ampersand)) return token;
- // Other tokens
- if (token = this.#readNextAddressToken(text, pos)) return token;
- if (token = this.#readNextNameToken(text, pos)) return token;
- if (token = this.#readNextNumberToken(text, pos)) return token;
- if (token = this.#readNextStringToken(text, pos)) return token;
- throw new CellSyntaxException(`Unexpected character "${text.substring(pos[0], pos[0] + 1)}" at ${pos[0]}`);
- }
-
- /**
- * @param {string} text
- * @param {number[]} pos
- */
- static #skipWhitespace(text, pos) {
- const l = text.length;
- while (pos[0] < l) {
- const ch = text.substring(pos[0], pos[0] + 1);
- if (ch == ' ' || ch == "\t" || ch == "\n" || ch == "\r") {
- pos[0]++;
- } else {
- return;
- }
- }
- }
-
- /**
- * @param {string} text
- * @param {number[]} pos
- * @param {string} target
- * @param {CellExpressionTokenType} type
- * @returns {CellExpressionToken|null}
- */
- static #readNextSimpleToken(text, pos, target, type) {
- const remaining = text.length - pos[0];
- const l = target.length;
- if (l > remaining) return null;
- const test = text.substring(pos[0], pos[0] + l);
- if (test.toUpperCase() != target.toUpperCase()) return null;
- pos[0] += l;
- return new CellExpressionToken(type, test);
- }
-
- /**
- * @param {string} text
- * @param {number[]} pos
- * @returns {CellExpressionToken|null}
- */
- static #readNextAddressToken(text, pos) {
- var p = [ pos[0] ];
- var ch = text.substring(p[0], p[0] + 1);
- var address = '';
- var isName = true;
- if (ch == '$') {
- address += ch;
- isName = false;
- p[0]++;
- }
- var col = this.#readChars(text, p, (s) => this.#isLetter(s), 1, 2);
- if (col === null) return null;
- address += col;
- ch = text.substring(p[0], p[0] + 1);
- if (ch == '$') {
- address += ch;
- isName = false;
- p[0]++;
- const row = this.#readChars(text, p, this.#isDigit, 1);
- if (row === null) return null;
- address += row;
- } else {
- const row = this.#readChars(text, p, this.#isDigit, 0);
- if (row === null) return null;
- address += row;
- }
- pos[0] = p[0];
- return new CellExpressionToken(
- isName ? CellExpressionTokenType.NameOrAddress : CellExpressionTokenType.Address,
- address);
- }
-
- /**
- * @param {string} text
- * @param {number[]} pos
- * @returns {CellExpressionToken|null}
- */
- static #readNextNameToken(text, pos) {
- var p = [ pos[0] ];
- const name = this.#readChars(text, p, (s) => this.#isLetter(s), 1);
- if (name === null) return null;
- pos[0] = p[0];
- if (CellAddress.isAddress(name)) {
- return new CellExpressionToken(CellExpressionTokenType.NameOrAddress, name);
- }
- return new CellExpressionToken(CellExpressionTokenType.Name, name);
- }
-
- /**
- * @param {string} text
- * @param {number[]} pos
- * @returns {CellExpressionToken|null}
- */
- static #readNextNumberToken(text, pos) {
- var ch = text.substring(pos[0], pos[0] + 1);
- if (!this.#isDigit(ch)) return null;
- const l = text.length;
- var numStr = ch;
- pos[0]++;
- while (pos[0] < l) {
- ch = text.substring(pos[0], pos[0] + 1);
- if (this.#isDigit(ch)) {
- pos[0]++;
- numStr += ch;
- } else {
- break;
- }
- }
- if (pos[0] < l) {
- ch = text.substring(pos[0], pos[0] + 1);
- if (ch == '.') {
- numStr += ch;
- pos[0]++;
- while (pos[0] < l) {
- ch = text.substring(pos[0], pos[0] + 1);
- if (this.#isDigit(ch)) {
- pos[0]++;
- numStr += ch;
- } else {
- break;
- }
- }
- }
- }
- return new CellExpressionToken(CellExpressionTokenType.Number, numStr);
- }
-
- /**
- * @param {string} text
- * @param {number[]} pos
- * @returns {CellExpressionToken|null}
- */
- static #readNextStringToken(text, pos) {
- var ch = text.substring(pos[0], pos[0] + 1);
- if (ch !== '"') return null;
- var str = '';
- pos[0]++;
- const l = text.length;
- var inEscape = false;
- while (pos[0] < l) {
- ch = text.substring(pos[0], pos[0] + 1);
- pos[0]++;
- if (inEscape) {
- inEscape = false;
- if (ch == '\\' || ch == '"') {
- str += ch;
- } else {
- throw new CellSyntaxException(`Bad string escape sequence "\\${ch}"`);
- }
- } else if (ch == '\\') {
- inEscape = true;
- } else if (ch == '"') {
- return new CellExpressionToken(CellExpressionTokenType.String, str);
- } else {
- str += ch;
- }
- }
- throw new CellSyntaxException('Unterminated string');
- }
-
- /**
- * Reads the next chars that pass a test function and returns the result.
- * @param {string} text
- * @param {number[]} pos
- * @param {function} charTest
- * @param {number|null} minimumLength
- * @param {number|null} maximumLength
- * @returns {string|null}
- */
- static #readChars(text, pos, charTest, minimumLength = null, maximumLength = null) {
- var p = pos[0];
- const l = text.length;
- var s = '';
- var sl = 0;
- while (p < l && (maximumLength === null || sl < maximumLength)) {
- const ch = text.substring(p, p + 1);
- if (!charTest(ch)) break;
- s += ch;
- sl++;
- p++;
- }
- if (p < l && charTest(text.substring(p, p + 1))) {
- return null;
- }
- if (minimumLength !== null && sl < minimumLength) {
- return null;
- }
- pos[0] = p;
- return s;
- }
-
- /**
- * @param {string} ch
- * @returns {boolean}
- */
- static #isLetter(ch) {
- const ord = ch.codePointAt(0);
- return (ord >= 65 && ord <= 90) || (ord >= 97 && ord <= 122);
- }
-
- /**
- * @param {string} ch
- * @returns {boolean}
- */
- static #isDigit(ch) {
- const ord = ch.codePointAt(0);
- return (ord >= 48 && ord <= 57);
- }
-
- // -- Parsing -----------------------------------------------------------
-
- /**
- * @param {Array} tokens
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static expressionFromTokens(tokens, address) {
- var expr;
- if (expr = this.#tryExpressionAndFormat(tokens, 0, tokens.length - 1, address)) return expr;
- if (expr = this.#tryExpressionAndFill(tokens, 0, tokens.length - 1, address)) return expr;
- if (expr = this.#tryExpression(tokens, 0, tokens.length - 1, address)) return expr;
- return null;
- }
-
- /**
- * @param {Array} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryExpressionAndFormat(tokens, start, end, address) {
- for (var t = start + 1; t < end; t++) {
- if (tokens[t].type == CellExpressionTokenType.Semicolon) {
- const expr = this.#tryExpressionAndFill(tokens, start, t - 1, address) ??
- this.#tryExpression(tokens, start, t - 1, address);
- if (expr === null) return null;
- const format = this.#tryFormat(tokens, t + 1, end, address);
- if (format === null) return null;
- [ expr.outputType, expr.outputDecimals ] = format;
- return expr;
- }
- }
- return null;
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryExpressionAndFill(tokens, start, end, address) {
- var count = end - start + 1;
- if (count < 2) return null;
- if (!tokens[end].type.isPotentialName()) return null;
- var name = tokens[end].content.toUpperCase();
- if (name != 'FILL') return null;
- const exp = this.#tryExpression(tokens, start, end - 1, address);
- const columnIndex = address.columnIndex;
- exp.fillRanges = [
- new CellAddressRange(new CellAddress(columnIndex, -1), new CellAddress(columnIndex, -1)),
- ];
- return exp;
- }
-
- /**
- * Tries to parse a format suffix after a semicolon. Examples:
- *
- * ```
- * ; number
- * ; number 3
- * ; currency 2
- * ; percent 0
- * ```
- *
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @return {any[]} CellValue type and decimal places
- */
- static #tryFormat(tokens, start, end, address) {
- const count = end - start + 1;
- if (count < 0 || count > 2) return null;
- if (!tokens[start].type.isPotentialName()) return null;
- const type = tokens[start].content.toLowerCase();
- if (!CellValue.isTypeNumeric(type)) return null;
- var decimals;
- if (count > 1) {
- if (tokens[start + 1].type != CellExpressionTokenType.Number) return null;
- decimals = parseInt(tokens[start + 1].content);
- } else {
- decimals = null;
- }
- return [ type, decimals ];
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression}
- */
- static #tryExpression(tokens, start, end, address) {
- var expr;
- if (expr = this.#tryParenExpression(tokens, start, end, address)) return expr;
- if (expr = this.#tryNumber(tokens, start, end, address)) return expr;
- if (expr = this.#tryString(tokens, start, end, address)) return expr;
- if (expr = this.#tryBoolean(tokens, start, end, address)) return expr;
- if (expr = this.#tryFunction(tokens, start, end, address)) return expr;
- if (expr = this.#tryRange(tokens, start, end, address)) return expr;
- if (expr = this.#tryReference(tokens, start, end, address)) return expr;
- if (expr = this.#tryInfix(tokens, start, end, address)) return expr;
- if (expr = this.#tryUnary(tokens, start, end, address)) return expr;
- throw new CellSyntaxException("Invalid expression");
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryParenExpression(tokens, start, end, address) {
- if (tokens[start].type != CellExpressionTokenType.OpenParen) return null;
- if (tokens[end].type != CellExpressionTokenType.CloseParen) return null;
- var parenLevel = 0;
- for (var t = start + 1; t < end; t++) {
- if (tokens[t].type == CellExpressionTokenType.OpenParen) {
- parenLevel++;
- } else if (tokens[t].type == CellExpressionTokenType.CloseParen) {
- parenLevel--;
- }
- if (parenLevel < 0) return null;
- }
- if (parenLevel != 0) return null;
- return this.#tryExpression(tokens, start + 1, end - 1, address);
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryNumber(tokens, start, end, address) {
- if (tokens[end].type != CellExpressionTokenType.Number) return null;
- if (end > start + 1) return null;
- const val = CellValue.fromCellString(tokens[end].content);
- if (end > start) {
- if (tokens[start].type != CellExpressionTokenType.Minus) return null;
- val.value = -val.value;
- }
- return new CellExpression(CellExpressionOperation.Number, [ val ]);
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryString(tokens, start, end, address) {
- if (start != end) return null;
- if (tokens[start].type != CellExpressionTokenType.String) return null;
- const str = tokens[start].content;
- return new CellExpression(CellExpressionOperation.String, [ new CellValue(str, str, CellValue.TYPE_STRING, 0) ]);
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryBoolean(tokens, start, end, address) {
- if (start != end) return null;
- if (!tokens[start].type.isPotentialName()) return null;
- const str = tokens[start].content.toUpperCase();
- if (str != 'TRUE' && str != 'FALSE') return null;
- return new CellExpression(CellExpressionOperation.Boolean, [ new CellValue(str, str == 'TRUE', CellValue.TYPE_BOOLEAN) ]);
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryFunction(tokens, start, end, address) {
- const count = end - start + 1;
- if (count < 3) return null;
- if (!tokens[start].type.isPotentialName()) return null;
- const qualifier = tokens[start].content;
- if (tokens[start + 1].type != CellExpressionTokenType.OpenParen) return null;
- if (tokens[end].type != CellExpressionTokenType.CloseParen) return null;
- const argList = this.#tryArgumentList(tokens, start + 2, end - 1, address);
- if (argList === null) return null;
- return new CellExpression(CellExpressionOperation.Function, argList, qualifier);
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression[]|null}
- */
- static #tryArgumentList(tokens, start, end, address) {
- const count = end - start + 1;
- if (count == 0) return [];
- var parenDepth = 0;
- const argCount = 1;
-
- // Populate argTokens with tuples of start and end token indices for each arg.
- /** @type {int[][]} */
- var argTokens = []; // argindex -> [ start, end ]
- var exprStartToken = start;
- for (var i = start; i <= end; i++) {
- if (tokens[i].type == CellExpressionTokenType.OpenParen) {
- parenDepth++;
- } else if (tokens[i].type == CellExpressionTokenType.CloseParen) {
- parenDepth--;
- } else if (tokens[i].type == CellExpressionTokenType.Comma && parenDepth == 0) {
- const exprEndToken = i - 1;
- argTokens.push([ exprStartToken, exprEndToken ]);
- exprStartToken = i + 1;
- }
- }
- argTokens.push([ exprStartToken, end ]);
-
- // Convert token ranges to expressions
- var args = [];
- for (const argToken of argTokens) {
- const arg = this.#tryExpression(tokens, argToken[0], argToken[1], address);
- if (arg === null) return null;
- args.push(arg);
- }
- return args;
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryRange(tokens, start, end, address) {
- const count = end - start + 1;
- if (count != 3) return null;
- if (!tokens[start].type.isPotentialAddress()) return null;
- const first = tokens[start].content.toUpperCase();
- if (tokens[start + 1].type != CellExpressionTokenType.Colon) return null;
- if (!tokens[end].type.isPotentialAddress()) return null;
- const last = tokens[end].content.toUpperCase();
- const firstAddress = CellAddress.fromString(first);
- const lastAddress = CellAddress.fromString(last);
- const range = new CellAddressRange(firstAddress, lastAddress);
- return new CellExpression(CellExpressionOperation.Range, [ range ]);
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryReference(tokens, start, end, address) {
- if (start != end) return null;
- if (!tokens[start].type.isPotentialAddress()) return null;
- const ref = tokens[start].content.toUpperCase();
- const refAddress = CellAddress.fromString(ref, address, true);
- return new CellExpression(CellExpressionOperation.Reference, [ refAddress ]);
- }
-
- static #infixPriority = {};
- static {
- this.#infixPriority[CellExpressionTokenType.Minus.name] = 1;
- this.#infixPriority[CellExpressionTokenType.Plus.name] = 2;
- this.#infixPriority[CellExpressionTokenType.Divide.name] = 3;
- this.#infixPriority[CellExpressionTokenType.Multiply.name] = 4;
- this.#infixPriority[CellExpressionTokenType.Ampersand.name] = 10;
- this.#infixPriority[CellExpressionTokenType.GreaterThan.name] = 20;
- this.#infixPriority[CellExpressionTokenType.GreaterThanEqual.name] = 20;
- this.#infixPriority[CellExpressionTokenType.LessThan.name] = 20;
- this.#infixPriority[CellExpressionTokenType.LessThanEqual.name] = 20;
- this.#infixPriority[CellExpressionTokenType.Equal.name] = 20;
- this.#infixPriority[CellExpressionTokenType.Unequal.name] = 20;
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryInfix(tokens, start, end, address) {
- const count = end - start + 1;
- if (count < 3) return null;
- var candidates = [];
- var parenLevel = 0;
- var i;
- for (i = start; i <= end; i++) {
- if (tokens[i].type == CellExpressionTokenType.OpenParen) {
- parenLevel++;
- } else if (tokens[i].type == CellExpressionTokenType.CloseParen) {
- parenLevel--;
- } else if (parenLevel == 0 && i > start && i < end) {
- const op = tokens[i].type.name;
- const priority = this.#infixPriority[op] ?? false;
- if (priority === false) continue;
- candidates.push({ priority: priority, i: i });
- }
- }
- candidates.sort((a, b) => a.priority - b.priority);
- var bestCandidate = null;
- var operand1, operand2;
- for (const candidate of candidates) {
- try {
- i = candidate.i;
- operand1 = this.#tryExpression(tokens, start, i - 1, address);
- if (operand1 === null) continue;
- operand2 = this.#tryExpression(tokens, i + 1, end, address);
- if (operand2 === null) continue;
- bestCandidate = candidate;
- break;
- } catch (e) {
- if (!(e instanceof CellSyntaxException)) {
- throw e;
- }
- }
- }
- if (bestCandidate === null) {
- return null;
- }
- i = bestCandidate.i;
- switch (tokens[bestCandidate.i].type) {
- case CellExpressionTokenType.Plus:
- return new CellExpression(CellExpressionOperation.Add, [ operand1, operand2 ]);
- case CellExpressionTokenType.Minus:
- return new CellExpression(CellExpressionOperation.Subtract, [ operand1, operand2 ]);
- case CellExpressionTokenType.Multiply:
- return new CellExpression(CellExpressionOperation.Multiply, [ operand1, operand2 ]);
- case CellExpressionTokenType.Divide:
- return new CellExpression(CellExpressionOperation.Divide, [ operand1, operand2 ]);
- case CellExpressionTokenType.GreaterThan:
- return new CellExpression(CellExpressionOperation.GreaterThan, [ operand1, operand2 ]);
- case CellExpressionTokenType.GreaterThanEqual:
- return new CellExpression(CellExpressionOperation.GreaterThanEqual, [ operand1, operand2 ]);
- case CellExpressionTokenType.LessThan:
- return new CellExpression(CellExpressionOperation.LessThan, [ operand1, operand2 ]);
- case CellExpressionTokenType.LessThanEqual:
- return new CellExpression(CellExpressionOperation.LessThanEqual, [ operand1, operand2 ]);
- case CellExpressionTokenType.Equal:
- return new CellExpression(CellExpressionOperation.Equal, [ operand1, operand2 ]);
- case CellExpressionTokenType.Unequal:
- return new CellExpression(CellExpressionOperation.Unequal, [ operand1, operand2 ]);
- case CellExpressionTokenType.Ampersand:
- return new CellExpression(CellExpressionOperation.Concatenate, [ operand1, operand2 ]);
- }
- return null;
- }
-
- /**
- * @param {CellExpressionToken[]} tokens
- * @param {number} start
- * @param {number} end
- * @param {CellAddress} address
- * @returns {CellExpression|null}
- */
- static #tryUnary(tokens, start, end, address) {
- const count = end - start + 1;
- if (count < 2) return null;
- const ops = [
- [ CellExpressionTokenType.Minus, CellExpressionOperation.UnaryMinus ],
- [ CellExpressionTokenType.Not, CellExpressionOperation.UnaryNot ],
- ];
- for (const op of ops) {
- if (tokens[start].type != op[0]) continue;
- const operand = this.#tryExpression(tokens, start + 1, end, address);
- if (operand === null) return null;
- return new CellExpression(op[1], [ operand ]);
- }
- return null;
- }
- }
-
- /**
- * Parsing token for an expression.
- */
- class CellExpressionToken {
- /** @type {CellExpressionTokenType} */
- type;
- /** @type {string} */
- content;
- /**
- * @param {CellExpressionTokenType} type
- * @param {string} content
- */
- constructor(type, content) {
- this.type = type;
- this.content = content;
- }
- }
-
- /**
- * The location of a cell in a table. If the address was specified without a
- * row, the address is considered "unresolved" and needs more context to
- * uniquely identify a cell.
- */
- class CellAddress {
- /**
- * @type {string}
- */
- get name() { return this.#name; }
- #name;
-
- /**
- * Whether the column should remain unchanged when transposed. This is
- * symbolized by prefixing the column name with a `$` (e.g. `$C3`).
- * @type {boolean}
- */
- get isColumnFixed() { return this.#isColumnFixed; }
- #isColumnFixed = false;
-
- /**
- * Zero-based column index.
- * @type {number}
- */
- get columnIndex() { return this.#columnIndex; };
- #columnIndex = -1;
-
- /**
- * Letter code for the column.
- * @type {string}
- */
- get columnLetter() { return CellAddress.#columnIndexToLetters(this.#columnIndex); }
-
- /**
- * Whether the row should remain unchanged when transposed. This is
- * symbolized by prefixing the row number with a `$` (e.g. `C$3`).
- * @type {boolean}
- */
- get isRowFixed() { return this.#isRowFixed; }
- #isRowFixed = false;
-
- /**
- * Zero-based row index.
- * @type {number}
- */
- get rowIndex() { return this.#rowIndex; }
- #rowIndex = -1;
-
- /**
- * One-based row number. This is the human-facing row number.
- */
- get rowNumber() { return this.#rowIndex + 1; }
-
- /**
- * Whether this address has both a definite column and row.
- * @type {boolean}
- */
- get isResolved() { return this.columnIndex >= 0 && this.rowIndex >= 0; }
-
- /**
- * @param {number} columnIndex - 0-based column index
- * @param {number} rowIndex - 0-based row index
- * @param {boolean} isColumnFixed - whether the column name is fixed in
- * place during transpositions. Denoted with a `$` in front of the column letters.
- * @param {boolean} isRowFixed - whether the row number is fixed in place
- * during transpositions. Denoted with a `$` in front of the row digits.
- */
- constructor(columnIndex, rowIndex, isColumnFixed=false, isRowFixed=false) {
- if (typeof columnIndex != 'number') {
- throw new Error(`columnIndex must be number, got ${typeof columnIndex}`);
- }
- if (typeof rowIndex != 'number') {
- throw new Error(`rowIndex must be number, got ${typeof rowIndex}`);
- }
- this.#columnIndex = columnIndex;
- this.#rowIndex = rowIndex;
- this.#isColumnFixed = isColumnFixed;
- this.#isRowFixed = isRowFixed;
- this.#name = CellAddress.#formatAddress(columnIndex, rowIndex, isColumnFixed, isRowFixed);
- }
-
- /**
- * Tests if a string is formatted like an address.
- *
- * @param {string} text
- * @returns {boolean}
- */
- static isAddress(text) {
- return this.fromString(text) != null;
- }
-
- /**
- * Returns a converted form of this address reference in a formula that has
- * been copied from its original location. In other words, if a formula
- * refers to a cell one to the left and that formula is copied to the next
- * cell down, that copy's reference should point to the cell on the next
- * row as well. Addresses with an absolute column or row (e.g. "A5") will
- * not be altered on that axis.
- *
- * Examples:
- * - C6.transpose(A5, A9) = C10 (A9-A5 = +4 rows, C6 + 4 rows = C10)
- * - C6.transpose(A5, B9) = D10 (B9-A5 = +4 rows +1 cols, C6 + 4 rows + 1
- * col = D10)
- * - C$6.transpose(A5, A9) = C6 (A9-A5 = +4 rows, but row is fixed, so
- * still C6)
- * - B.transpose(A5, A9) = B9 (A9-A4 = +4 rows, B has no row so last row
- * used = B9)
- * - A1.transpose(C3, A1) = null (out of bounds)
- *
- * @param {CellAddress} relativeFrom - original address of the formula
- * @param {CellAddress} relativeTo - address where the formula is being
- * repeated
- * @param {boolean} resolveToRow - whether to fill in a row number if this
- * address doesn't have one
- * @returns {CellAddress|null} - resolved address, or `null` if out of bounds
- */
- transpose(relativeFrom, relativeTo, resolveToRow = true) {
- if (!relativeFrom.isResolved || !relativeTo.isResolved) {
- throw new CellEvaluationException("Can only transpose to and from resolved addresses");
- }
- var newColumnIndex = this.columnIndex;
- if (!this.isColumnFixed) {
- const columnDelta = relativeTo.columnIndex - relativeFrom.columnIndex;
- newColumnIndex += columnDelta;
- }
- var newRowIndex = this.rowIndex;
- if (!this.isResolved && resolveToRow) {
- newRowIndex = relativeFrom.rowIndex;
- }
- if (newRowIndex != -1 && !this.isRowFixed) {
- const rowDelta = relativeTo.rowIndex - relativeFrom.rowIndex;
- newRowIndex += rowDelta;
- }
- if (newColumnIndex < 0 || newRowIndex < 0) return null;
- return new CellAddress(newColumnIndex, newRowIndex);
- }
-
- equals(other) {
- if (!(other instanceof CellAddress)) return false;
- return other.columnIndex == this.columnIndex && other.rowIndex == this.rowIndex;
- }
-
- exactlyEquals(other) {
- if (!(other instanceof CellAddress)) return false;
- return other.name == this.name;
- }
-
- toString() {
- return this.name;
- }
-
- /**
- * Converts column letters (e.g. `A`, `C`, `AA`) to a 0-based column index.
- * Assumes a validated well-formed column letter or else behavior is undefined.
- *
- * @param {string} letters
- * @returns {number} column index
- */
- static #lettersToColumnIndex(letters) {
- const ACodepoint = 'A'.codePointAt(0);
- var columnIndex = 0;
- for (var i = letters.length - 1; i >= 0; i--) {
- const letterIndex = letters.codePointAt(i) - ACodepoint;
- columnIndex = columnIndex * 26 + letterIndex;
- }
- return columnIndex;
- }
-
- /**
- * Converts a column index to column letters (e.g. index 0 = `A`).
- *
- * @param {number} columnIndex
- * @returns {string}
- */
- static #columnIndexToLetters(columnIndex) {
- var letters = '';
- if (columnIndex >= 0) {
- const ACodepoint = 'A'.codePointAt(0);
- var remaining = columnIndex;
- do {
- letters = String.fromCodePoint(ACodepoint + (remaining % 26)) + letters;
- remaining = Math.floor(remaining / 26);
- } while (remaining > 0);
- }
- return letters;
- }
-
- static #formatAddress(columnIndex, rowIndex, isColumnFixed, isRowFixed) {
- var addr = '';
- if (isColumnFixed && columnIndex >= 0) addr += '$';
- if (columnIndex >= 0) addr += this.#columnIndexToLetters(columnIndex);
- if (isRowFixed && rowIndex >= 0) addr += '$';
- if (rowIndex >= 0) addr += `${rowIndex + 1}`;
- return addr;
- }
-
- /**
- * Attempts to convert a cell address string to a `CellAddress`.
- *
- * @param {string} address - cell address string
- * @param {CellAddress|null} relativeTo - address to resolve relative
- * addresses against
- * @param {boolean} throwIfInvalid - whether to throw an error if address
- * is invalid
- * @returns {CellAddress|null} address, if parsable
- * @throws {CellEvaluationException} if the address is invalid and
- * `throwIfInvalid` is `true`
- */
- static fromString(address, relativeTo=null, throwIfInvalid=false) {
- const groups = /^(\$?)([A-Z]{1,2}?)((?:\$(?=[0-9]))?)([0-9]*)$/i.exec(address);
- if (groups === null) {
- if (throwIfInvalid) throw new CellEvaluationException(`Bad address "${address}"`, '#REF');
- return null;
- }
- const isColumnFixed = groups[1] == '$';
- const letters = groups[2].toUpperCase();
- const isRowFixed = groups[3] == '$';
- const numbers = groups[4];
- var columnIndex = this.#lettersToColumnIndex(letters);
- var rowIndex = (numbers.length == 0) ? -1 : parseInt(numbers) - 1;
- if (columnIndex < 0 && relativeTo != null) columnIndex = relativeTo.columnIndex;
- if (rowIndex < 0 && relativeTo != null) rowIndex = relativeTo.rowIndex;
- return new CellAddress(columnIndex, rowIndex, isColumnFixed, isRowFixed);
- }
- }
-
- /**
- * Range of cells addresses. Can be iterated via `cellsIn`.
- */
- class CellAddressRange {
- /** @type {boolean} */
- isResolved;
- /** @type {number} */
- minColumnIndex;
- /** @type {number} */
- maxColumnIndex;
- /** @type {number} */
- minRowIndex;
- /** @type {number} */
- maxRowIndex;
- /** @type {string} */
- name;
-
- /**
- * Creates a rectangular range between two corner cells. They can be in
- * any order. The given cells must either both be resolved or both unresolved.
- *
- * @param {CellAddress} fromCell
- * @param {CellAddress} toCell
- */
- constructor(fromCell, toCell) {
- if (fromCell.isResolved != toCell.isResolved) {
- throw new CellEvaluationException(`Cannot mix resolved and unresolved cell addresses in range: ${fromCell.name} and ${toCell.name}`);
- }
- this.minColumnIndex = Math.min(fromCell.columnIndex, toCell.columnIndex);
- this.maxColumnIndex = Math.max(fromCell.columnIndex, toCell.columnIndex);
- this.minRowIndex = Math.min(fromCell.rowIndex, toCell.rowIndex);
- this.maxRowIndex = Math.max(fromCell.rowIndex, toCell.rowIndex);
- this.isResolved = fromCell.isResolved;
- this.name = (new CellAddress(this.minColumnIndex, this.minRowIndex)).name +
- ':' +
- (new CellAddress(this.maxColumnIndex, this.maxRowIndex)).name;
- }
-
- /**
- * Creates an iterator for every `CellAddress` in this range within the
- * confines of the given grid's dimensions. Iterates each row in the first
- * column, then each row in the second, etc. Iteration range is inclusive
- * of the min and max extents.
- *
- * Example:
- * ```
- * for (const address of range.cellsIn(grid)) {
- * ...
- * }
- * ```
- *
- * @param {SpreadsheetGrid} grid
- * @returns {object} iterable object
- */
- cellsIn(grid) {
- const minCol = this.minColumnIndex < 0 ? 0 : this.minColumnIndex;
- const maxCol = this.maxColumnIndex < 0 ? grid.columnCount - 1
- : Math.min(this.maxColumnIndex, grid.columnCount - 1);
- const minRow = this.minRowIndex < 0 ? 0 : this.minRowIndex;
- const maxRow = this.maxRowIndex < 0 ? grid.rowCount - 1
- : Math.min(this.maxRowIndex, grid.rowCount - 1);
- const iterable = {};
- iterable[Symbol.iterator] = function() {
- var currentCol = minCol;
- var currentRow = minRow;
- return {
- next() {
- const col = currentCol;
- const row = currentRow++;
- if (currentRow > maxRow) {
- currentRow = minRow;
- currentCol++;
- }
- return {
- value: new CellAddress(col, row),
- done: col > maxCol || row > maxRow,
- };
- }
- };
- };
- return iterable;
- }
- }
-
- /**
- * A value in a spreadsheet or calculation.
- */
- class CellValue {
- /**
- * Blank cell. `value` is `null`.
- */
- static TYPE_BLANK = 'blank';
- /**
- * Currency value. `value` is `number`.
- */
- static TYPE_CURRENCY = 'currency';
- /**
- * Regular number value. `value` is `number`.
- */
- static TYPE_NUMBER = 'number';
- /**
- * Percentage. `value` is `number`, represented as a ratio (100% = 1.0).
- */
- static TYPE_PERCENT = 'percent';
- /**
- * Unaltered text value. `value` is `string`.
- */
- static TYPE_STRING = 'string';
- /**
- * Boolean. `value` is `boolean`.
- */
- static TYPE_BOOLEAN = 'boolean';
- /**
- * A formula that has resulted in an error during parsing or evaluation.
- * `value` is `string` error message.
- */
- static TYPE_ERROR = 'error';
- /**
- * A formula expression. `value` is `string` and includes the leading `=`.
- */
- static TYPE_FORMULA = 'formula';
-
- // -- Properties -----
-
- /**
- * A blank value.
- * @type {CellValue}
- */
- static BLANK = new CellValue('', null, CellValue.TYPE_BLANK);
-
- /**
- * Type of value. One of the `TYPE_` constants.
- * @type {string}
- */
- type = CellValue.TYPE_STRING;
- /**
- * Number of decimal places shown in the formatted value.
- * @type {number}
- */
- decimals = 0;
- /**
- * The string shown in the table cell to the user.
- * @type {string}
- */
- formattedValue = '';
- /**
- * The PHP data value. E.g. a `float` for currency values or an `Exception`
- * for errors.
- * @type {any}
- */
- value = null;
-
- /**
- * Constructs a cell value explicitly. Values are not validated. Consider
- * using `.fromCellString()` or `.fromValue()` to populate values more
- * intelligently and consistently.
- *
- * @param {string} formattedValue
- * @param {any} value
- * @param {string} type
- * @param {number} decimals
- */
- constructor(
- formattedValue,
- value = null,
- type = CellValue.TYPE_STRING,
- decimals = 0
- ) {
- this.formattedValue = formattedValue;
- this.value = value;
- this.type = type;
- this.decimals = decimals;
- }
-
- /**
- * Returns whether this value is a numeric type.
- *
- * @returns {boolean}
- */
- isNumeric() {
- return CellValue.isTypeNumeric(this.type);
- }
-
- /**
- * Creates a CellValue from formatted table cell contents. Attempts to
- * detect formatted numbers including currency and percentages.
- *
- * @param {string} cellString
- * @returns {CellValue}
- */
- static fromCellString(cellString) {
- const cv = new CellValue(cellString);
- cv.#populateFromCellString(cellString);
- return cv;
- }
-
- /**
- * Creates a CellValue from a value. Based off datatype, not string
- * formatting; use `fromCellString` to parse formatted numbers.
- *
- * @param {any} value
- * @param {string|null} type - optional forced type
- * @param {number|null} decimals - optional number of decimal places to format to
- * @returns {CellValue}
- * @throws {CellEvaluationException} if `value` is of an unsupported type
- */
- static fromValue(value, type = null, decimals = null) {
- if (value === null) {
- return new CellValue('', null, CellValue.TYPE_BLANK);
- }
- if (value instanceof Error) {
- if (value instanceof CellException) {
- return new CellValue(value.errorSymbol, value.message, CellValue.TYPE_ERROR);
- }
- return new CellValue('#ERROR', value.message, CellValue.TYPE_ERROR);
- }
- if (typeof value == 'boolean') {
- const formatted = CellValue.formatType(value, CellValue.TYPE_BOOLEAN, 0);
- return new CellValue(formatted, value, CellValue.TYPE_BOOLEAN);
- }
- if (typeof value == 'number') {
- const resolvedType = type || CellValue.TYPE_NUMBER;
- const resolvedDecimals = (decimals !== null) ? decimals :
- (resolvedType == CellValue.TYPE_CURRENCY ? 2 : CellValue.#autodecimals(resolvedType == CellValue.TYPE_PERCENT ? value * 100.0 : value));
- const formatted = CellValue.formatType(value, resolvedType, resolvedDecimals);
- return new CellValue(formatted, value, resolvedType, resolvedDecimals);
- }
- if (typeof value != 'string') {
- throw new CellEvaluationException(`Value of type ${typeof value} unsupported`);
- }
- const trimmed = value.trim();
- if (trimmed.startsWith('=')) {
- return new CellValue(trimmed, trimmed, CellValue.TYPE_FORMULA);
- }
- return new CellValue(trimmed, trimmed, CellValue.TYPE_STRING);
- }
-
- /**
- * @param {string|null} cellString
- */
- #populateFromCellString(cellString) {
- cellString = (cellString !== null) ? cellString.trim() : null;
- this.formattedValue = cellString;
- // blank
- if (cellString === null || cellString == '') {
- this.type = CellValue.TYPE_BLANK;
- this.value = null;
- return;
- }
- // 'literal
- if (cellString.startsWith("'")) {
- const stripped = cellString.substring(1).trim();
- this.type = CellValue.TYPE_STRING;
- this.formattedValue = stripped;
- this.value = stripped;
- return;
- }
- // =TRUE
- const caps = cellString.toUpperCase();
- if (caps == 'TRUE') {
- this.type = CellValue.TYPE_BOOLEAN;
- this.formattedValue = caps;
- this.value = true;
- this.formattedValue = 'TRUE';
- return;
- }
- // =FALSE
- if (caps == 'FALSE') {
- this.type = CellValue.TYPE_BOOLEAN;
- this.formattedValue = caps;
- this.value = false;
- this.formattedValue = 'FALSE';
- return;
- }
- // =A*B
- if (cellString.startsWith('=')) {
- this.type = CellValue.TYPE_FORMULA;
- this.value = cellString;
- return;
- }
- // -$1,234.56
- var groups;
- if (groups = /^([-]?)\$(-?[0-9,]*\.)([0-9]+)$/.exec(cellString)) {
- const sign = groups[1];
- const dollars = groups[2].replace(/,/g, '');
- const cents = groups[3];
- this.type = CellValue.TYPE_CURRENCY;
- this.decimals = 2;
- this.value = parseFloat(sign + dollars + cents);
- this.formattedValue = CellValue.#formatCurrency(this.value, this.decimals);
- return;
- }
- // -$1,234
- if (groups = /^([-]?)\$(-?[0-9,]+)$/.exec(cellString)) {
- const sign = groups[1];
- const dollars = groups[2].replace(/,/g, '');
- this.type = CellValue.TYPE_CURRENCY;
- this.decimals = 0;
- this.value = parseFloat(sign + dollars);
- this.formattedValue = CellValue.#formatCurrency(this.value, this.decimals);
- return;
- }
- // -1,234.56%
- if (groups = /^([-]?[0-9,]*\.)([0-9,]+)%$/.exec(cellString)) {
- const wholes = groups[1].replace(/,/, '');
- const decimals = groups[2];
- this.type = CellValue.TYPE_PERCENT;
- this.decimals = decimals.length;
- this.value = parseFloat(wholes + decimals) / 100.0;
- this.formattedValue = CellValue.#formatPercent(this.value, this.decimals);
- return;
- }
- // -1,234%
- if (groups = /^([-]?[0-9,]+)%$/.exec(cellString)) {
- const wholes = groups[1].replace(/,/g, '');
- this.type = CellValue.TYPE_PERCENT;
- this.decimals = 0;
- this.value = parseFloat(wholes) / 100.0;
- this.formattedValue = CellValue.#formatPercent(this.value, this.decimals);
- return;
- }
- // -1,234.56
- if (groups = /^([-]?[0-9,]*\.)([0-9]+)$/.exec(cellString)) {
- const wholes = groups[1].replace(/,/g, '');
- const decimals = groups[2];
- this.type = CellValue.TYPE_NUMBER;
- this.decimals = decimals.length;
- this.value = parseFloat(wholes + decimals);
- this.formattedValue = CellValue.#formatNumber(this.value, this.decimals);
- return;
- }
- // -1,234
- if (groups = /^([-]?[0-9,]+)$/.exec(cellString)) {
- const wholes = groups[1].replace(/,/g, '');
- this.type = CellValue.TYPE_NUMBER;
- this.decimals = 0;
- this.value = parseFloat(wholes);
- this.formattedValue = CellValue.#formatNumber(this.value, this.decimals);
- return;
- }
- this.type = CellValue.TYPE_STRING;
- this.value = cellString;
- }
-
- /**
- * Returns the boolean equivalent of this value if possible.
- *
- * @returns {boolean|null}
- */
- booleanValue() {
- switch (this.type) {
- case CellValue.TYPE_BLANK:
- return false;
- case CellValue.TYPE_BOOLEAN:
- return this.value;
- case CellValue.TYPE_CURRENCY:
- case CellValue.TYPE_NUMBER:
- case CellValue.TYPE_PERCENT:
- return this.value != 0;
- case CellValue.TYPE_ERROR:
- case CellValue.TYPE_FORMULA:
- case CellValue.TYPE_STRING:
- return null;
- }
- }
-
- /**
- * Returns the numeric value of this value if possible.
- *
- * @returns {number|null}
- */
- numericValue() {
- switch (this.type) {
- case CellValue.TYPE_BLANK:
- return 0.0;
- case CellValue.TYPE_BOOLEAN:
- return this.value ? 1.0 : 0.0;
- case CellValue.TYPE_CURRENCY:
- case CellValue.TYPE_NUMBER:
- case CellValue.TYPE_PERCENT:
- return this.value;
- case CellValue.TYPE_ERROR:
- case CellValue.TYPE_FORMULA:
- case CellValue.TYPE_STRING:
- return null;
- }
- }
-
- /**
- * Returns the string value of this value if possible.
- *
- * @param {boolean} formatted
- * @returns {string|null}
- */
- stringValue(formatted = false) {
- switch (this.type) {
- case CellValue.TYPE_BLANK:
- return '';
- case CellValue.TYPE_BOOLEAN:
- return this.value ? 'TRUE' : 'FALSE';
- case CellValue.TYPE_CURRENCY:
- case CellValue.TYPE_NUMBER:
- case CellValue.TYPE_PERCENT:
- return formatted ? this.formattedValue : `${this.value}`;
- case CellValue.TYPE_STRING:
- return formatted ? this.formattedValue : this.value;
- case CellValue.TYPE_ERROR:
- case CellValue.TYPE_FORMULA:
- return null;
- }
- }
-
- // -- Operations --------------------------------------------------------
-
- /**
- * Returns the result of this value plus `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue} sum
- */
- add(b) {
- return CellValue.#binaryNumericOperation(this, b, '+',
- (aVal, bVal) => aVal + bVal);
- }
-
- /**
- * Returns the result of this value minus `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue} difference
- */
- subtract(b) {
- return CellValue.#binaryNumericOperation(this, b, '-',
- (aVal, bVal) => aVal - bVal);
- }
-
- /**
- * Returns the result of this value multiplied by `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue} product
- */
- multiply(b) {
- return CellValue.#binaryNumericOperation(this, b, '*',
- (aVal, bVal) => aVal * bVal);
- }
-
- /**
- * Returns the result of this value divided by `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue} quotient
- * @throws {CellEvaluationException} on divide by zero
- */
- divide(b) {
- return CellValue.#binaryNumericOperation(this, b, '/', (aVal, bVal) => {
- if (bVal == 0) throw new CellEvaluationException("Division by zero", '#NAN');
- return aVal / bVal
- });
- }
-
- /**
- * Returns the result of this value modulo by `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue} remainder
- * @throws {CellEvaluationException} on divide by zero
- */
- modulo(b) {
- return CellValue.#binaryNumericOperation(this, b, '%', (aVal, bVal) => {
- if (bVal == 0) throw new CellEvaluationException("Division by zero", '#NAN');
- return aVal % bVal
- });
- }
-
- /**
- * Returns the result of whether this value is greater than `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue}
- */
- gt(b) {
- return CellValue.fromValue(CellValue.#compare(this, b) > 0);
- }
-
- /**
- * Returns the result of whether tihs value is greater than or equal to `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue}
- */
- gte(b) {
- return CellValue.fromValue(CellValue.#compare(this, b) >= 0);
- }
-
- /**
- * Returns the result of whether this value is less than `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue}
- */
- lt(b) {
- return CellValue.fromValue(CellValue.#compare(this, b) < 0);
- }
-
- /**
- * Returns the result of whether this value is less than or equal to `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue}
- */
- lte(b) {
- return CellValue.fromValue(CellValue.#compare(this, b) <= 0);
- }
-
- /**
- * Returns the result of whether this value is equal to `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue}
- */
- eq(b) {
- return CellValue.fromValue(CellValue.#compare(this, b) == 0);
- }
-
- /**
- * Returns the result of whether this value is unequal to `b`.
- *
- * @param {CellValue} b
- * @returns {CellValue}
- */
- neq(b) {
- return CellValue.fromValue(CellValue.#compare(this, b) != 0);
- }
-
- /**
- * Returns the boolean not of this value.
- *
- * @returns {CellValue}
- */
- not() {
- switch (this.type) {
- case CellValue.TYPE_BLANK:
- return CellValue.fromValue(true);
- case CellValue.TYPE_CURRENCY:
- case CellValue.TYPE_NUMBER:
- case CellValue.TYPE_PERCENT:
- return CellValue.fromValue(this.value == 0);
- case CellValue.TYPE_STRING:
- throw new CellEvaluationException("Cannot perform NOT on string");
- case CellValue.TYPE_BOOLEAN:
- return CellValue.fromValue(!this.value);
- case CellValue.TYPE_ERROR:
- throw this.value;
- case CellValue.TYPE_FORMULA:
- throw new CellEvaluationException("Cannot perform NOT on expression");
- }
- }
-
- /**
- * Returns the string representation of a value concatenated to the string
- * representation of this value.
- *
- * @param {CellValue} b
- * @returns {CellValue}
- */
- concatenate(b) {
- const s1 = this.stringValue(true);
- const s2 = b.stringValue(true);
- if (s1 === null || s2 === null) {
- throw new CellEvaluationException("Concatenation requires string arguments");
- }
- return CellValue.fromValue(`${s1}${s2}`);
- }
-
- /**
- * Helper to resolve two numeric arguments and perform an operation on them.
- *
- * @param {CellValue} a - operand A
- * @param {CellValue} b - operand B
- * @param {string} op - operator
- * @param {function} fn - takes two `number` arguments and returns a `number` result
- * @returns {CellValue} result
- * @throws {CellEvaluationException}
- */
- static #binaryNumericOperation(a, b, op, fn) {
- const ops = this.#resolveNumericOperands(a, b, op);
- const aNum = ops[0];
- const bNum = ops[1];
- const type = ops[2];
- const result = fn(aNum, bNum);
- return CellValue.fromValue(result, type);
- }
-
- /**
- * Determines what the result of a calculation with two operands should
- * look like. Returns a tuple array of the A float value, the B float value,
- * and the type of the result.
- *
- * @param {CellValue} a - operand A
- * @param {CellValue} b - operand B
- * @param {string} op - operator symbol
- * @returns {Array} 3-element tuple array with A number value, B number value,
- * and result type string
- * @throws {CellEvaluationException} if types are incompatible for numeric operations
- */
- static #resolveNumericOperands(a, b, op) {
- if (a.type == this.TYPE_ERROR) throw a.value;
- if (b.type == this.TYPE_ERROR) throw b.value;
- if (a.type == this.TYPE_STRING || b.type == this.TYPE_STRING) {
- throw new CellEvaluationException("Cannot perform math on text values");
- }
-
- if (a.type == this.TYPE_BLANK) {
- if (b.type == this.TYPE_BLANK) {
- return [ 0, 0, this.TYPE_NUMBER, 0 ];
- }
- return [ 0, b.value, b.type ];
- } else if (b.type == this.TYPE_BLANK) {
- return [ a.value, 0, a.type ];
- }
-
- const isMultOrDiv = (op == '*' || op == '/' || op == '%');
-
- if (a.type == b.type) {
- return [ a.value, b.value, a.type ];
- }
-
- switch (a.type + b.type) {
- case this.TYPE_CURRENCY + this.TYPE_NUMBER:
- case this.TYPE_CURRENCY + this.TYPE_PERCENT:
- return [ a.value, b.value, this.TYPE_CURRENCY ];
- case this.TYPE_PERCENT + this.TYPE_CURRENCY:
- return [ a.value, b.value,
- isMultOrDiv ? this.TYPE_CURRENCY : this.TYPE_PERCENT ];
- case this.TYPE_PERCENT + this.TYPE_NUMBER:
- return [ a.value, b.value,
- isMultOrDiv ? this.TYPE_NUMBER : this.TYPE_PERCENT ];
- case this.TYPE_NUMBER + this.TYPE_CURRENCY:
- return [ a.value, b.value, b.type ];
- case this.TYPE_NUMBER + this.TYPE_PERCENT:
- return [ a.value, b.value,
- isMultOrDiv ? this.TYPE_NUMBER : b.type ];
- case this.TYPE_BOOLEAN + this.TYPE_CURRENCY:
- case this.TYPE_BOOLEAN + this.TYPE_NUMBER:
- case this.TYPE_BOOLEAN + this.TYPE_PERCENT:
- return [ a.value ? 1 : 0, b.value, b.type ];
- case this.TYPE_CURRENCY + this.TYPE_BOOLEAN:
- case this.TYPE_NUMBER + this.TYPE_BOOLEAN:
- case this.TYPE_PERCENT + this.TYPE_BOOLEAN:
- return [ a.value, b.value ? 1 : 0, a.type ];
- }
- throw new CellEvaluationException(`Unhandled operand types "${a.type}" and "${b.type}"`);
- }
-
- /**
- * Performs a comparison of two values.
- *
- * @param {CellValue} a
- * @param {CellValue} b
- * @returns {number} `-1`, `0`, or `1` if a < b, a == b, or a > b, respectively
- */
- static #compare(a, b) {
- const args = CellValue.#resolveComparableArguments(a, b);
- const valueA = args[0];
- const valueB = args[1];
- if (typeof valueA == 'string') {
- return valueA.localeCompare(valueB, undefined, { sensitivity: 'accent' });
- } else {
- if (valueA < valueB) return -1;
- if (valueA > valueB) return 1;
- return 0;
- }
- }
-
- /**
- * @param {CellValue} a
- * @param {CellValue} b
- * @returns {Array}
- */
- static #resolveComparableArguments(a, b) {
- if (a.type == CellValue.TYPE_ERROR) throw a.value;
- if (b.type == CellValue.TYPE_ERROR) throw b.value;
- if (a.type == CellValue.TYPE_FORMULA) throw new CellEvaluationException("Can't compare formula values");
- if (b.type == CellValue.TYPE_FORMULA) throw new CellEvaluationException("Can't compare formula values");
- const aNumValue = a.value;
- const bNumValue = b.value;
- const aStrValue = `${a.value}`;
- const bStrValue = `${b.value}`;
- switch (a.type) {
- case CellValue.TYPE_BLANK:
- aNumValue = 0;
- aStrValue = '';
- break;
- case CellValue.TYPE_BOOLEAN:
- aNumValue = (aNumValue) ? 1 : 0;
- break;
- }
- switch (b.type) {
- case CellValue.TYPE_BLANK:
- bNumValue = 0;
- bStrValue = '';
- break;
- case CellValue.TYPE_BOOLEAN:
- bNumValue = (bNumValue) ? 1 : 0;
- break;
- }
- if (a.type == CellValue.TYPE_STRING || b.type == CellValue.TYPE_STRING) {
- return [ aStrValue, bStrValue ];
- }
- return [ aNumValue, bNumValue ];
- }
-
- /**
- * Returns a formatted string for the given raw value, value type, and
- * decimal places.
- *
- * @param {any} value
- * @param {string} type
- * @param {number} decimals
- * @returns {string} formatted value
- */
- static formatType(value, type, decimals) {
- switch (type) {
- case CellValue.TYPE_BLANK: return '';
- case CellValue.TYPE_CURRENCY: return CellValue.#formatCurrency(value, decimals);
- case CellValue.TYPE_NUMBER: return CellValue.#formatNumber(value, decimals);
- case CellValue.TYPE_PERCENT: return CellValue.#formatPercent(value, decimals);
- case CellValue.TYPE_BOOLEAN: return value ? 'TRUE' : 'FALSE';
- case CellValue.TYPE_STRING: return `${value}`;
- case CellValue.TYPE_FORMULA: return `${value}`;
- }
- }
-
- /**
- * @param {number} value
- * @param {number} decimals
- * @returns {string}
- */
- static #formatNumber(value, decimals) {
- return (value).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
- }
-
- /**
- * @param {number} dollars
- * @param {number} decimals
- * @returns {string}
- */
- static #formatCurrency(dollars, decimals) {
- var s = (dollars).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
- if (s.startsWith('-')) {
- return '-$' + s.substring(1);
- }
- return '$' + s;
- }
-
- /**
- * @param {number} value
- * @param {number} decimals
- * @returns {string}
- */
- static #formatPercent(value, decimals) {
- const dec = value * 100.0;
- return (dec).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + '%';
- }
-
- /**
- * Determines a good number of decimal places to format a value to.
- *
- * @param {any} value
- * @param {number} maxDigits
- * @returns {number} digit count
- */
- static #autodecimals(value, maxDigits = 6) {
- if (value instanceof CellValue) {
- return CellValue.#autodecimals(value.value);
- }
- if (typeof value == 'number') {
- var s = (value).toLocaleString(undefined, { maximumFractionDigits: maxDigits });
- if (/\./.exec(s) === null) return 0;
- var fraction = s.split('.')[1];
- return Math.min(maxDigits, fraction.length);
- }
- return 0;
- }
-
- /**
- * Tests if a type is numeric.
- *
- * @param {string} type
- * @returns {boolean}
- */
- static isTypeNumeric(type) {
- return type == CellValue.TYPE_NUMBER ||
- type == CellValue.TYPE_PERCENT ||
- type == CellValue.TYPE_CURRENCY ||
- type == CellValue.TYPE_BOOLEAN;
- }
-
- toString() {
- return `[CellValue type=${this.type} value=${this.value} "${this.formattedValue}"]`;
- }
- }
-
- /**
- * A rectangular grid of `SpreadsheetCell`s representing a spreadsheet.
- * Agnostic of presentation.
- */
- class SpreadsheetGrid {
- /**
- * Indexed by column then row.
- * @type {SpreadsheetCell[][]}
- */
- cells;
-
- /** @type {number} */
- columnCount;
- /** @type {number} */
- rowCount;
-
- /**
- * @param {number} columnCount
- * @param {number} rowCount
- */
- constructor(columnCount, rowCount) {
- this.columnCount = columnCount;
- this.rowCount = rowCount;
- this.cells = new Array(columnCount);
- for (var c = 0; c < columnCount; c++) {
- this.cells[c] = new Array(rowCount);
- for (var r = 0; r < rowCount; r++) {
- this.cells[c][r] = new SpreadsheetCell();
- }
- }
- }
-
- /**
- * @param {CellAddress} address
- * @returns {SpreadsheetCell|null} cell, or `null` if no cell available
- * @throws {CellEvaluationException} if the address it out of bounds
- */
- cellAt(address) {
- const c = address.columnIndex, r = address.rowIndex;
- if (c < 0 || c >= this.cells.length) throw new CellEvaluationException(`Unresolved cell address ${address.name}`, '#REF');
- const row = this.cells[c];
- if (r < 0 || r >= row.length) throw new CellEvaluationException(`Unresolved cell address ${address.name}`, '#REF');
- return row[r];
- }
-
- /**
- * Returns the original value at the given address.
- * @param {CellAddress} address
- */
- valueAt(address) {
- return this.cellAt(address)?.originalValue;
- }
-
- /**
- * Returns the computed value at the given address.
- * @param {CellAddress} address
- * @returns {CellValue|null} computed value or `null`
- */
- outputValueAt(address) {
- return this.cellAt(address)?.outputValue;
- }
- }
-
- /**
- * One cell in a spreadsheet grid. Has an `originalValue` to start and an
- * evaluated `outputValue` after population by a `CellExpressionSet` computation.
- */
- class SpreadsheetCell {
- /**
- * @type {CellValue}
- */
- originalValue = CellValue.BLANK;
-
- /**
- * @type {CellValue|null}
- */
- outputValue = null;
-
- /**
- * Whether `outputValue` is the result of a formula evaluation.
- * @type {boolean}
- */
- isCalculated = false;
-
- /** @type {CellExpression|null} */
- parsedExpression = null;
-
- /**
- * @type {CellValue|null}
- */
- get resolvedValue() { return this.outputValue ?? this.originalValue; }
- }
-
- /**
- * Integration with Markdown. Adding this block reader to a parser will run a
- * post-process step on any tables in the document tree. Must be used with
- * `MDTableReader`. Tables without at least one formula will not be altered.
- */
- class MDSpreadsheetReader extends MDReader {
- postProcess(state, nodes) {
- for (const node of nodes) {
- if (node instanceof MDTableNode) {
- this.#processTable(node, state);
- }
- }
- }
-
- /**
- * @param {MDTableNode} tableNode
- * @param {MDState} state
- */
- #processTable(tableNode, state) {
- // Measure table
- const rowCount = tableNode.bodyRows.length;
- var columnCount = 0;
- for (const row of tableNode.bodyRows) {
- columnCount = Math.max(columnCount, row.children.length);
- }
-
- // Create and populate grid
- const grid = new SpreadsheetGrid(columnCount, rowCount);
- for (var c = 0; c < columnCount; c++) {
- for (var r = 0; r < rowCount; r++) {
- const cellNode = tableNode.bodyRows[r].children[c];
- if (cellNode === undefined) continue;
- const cellText = cellNode.toPlaintext(state);
- const gridCell = grid.cells[c][r];
- gridCell.originalValue = CellValue.fromCellString(cellText);
- }
- }
-
- // Calculate
- const expressions = new CellExpressionSet(grid);
- expressions.calculateCells();
-
- // See if anything was calculated. If not, don't mess with table.
- var isCalculated = false;
- for (var c = 0; c < columnCount && !isCalculated; c++) {
- for (var r = 0; r < rowCount; r++) {
- if (grid.cells[c][r].isCalculated) {
- isCalculated = true;
- break;
- }
- }
- }
- if (!isCalculated) return;
-
- // Copy results back to table
- for (var c = 0; c < columnCount; c++) {
- for (var r = 0; r < rowCount; r++) {
- const cellNode = tableNode.bodyRows[r].children[c];
- if (cellNode === undefined) continue;
- const gridCell = grid.cells[c][r];
- const gridValue = gridCell.outputValue;
- const cellText = gridValue.formattedValue;
- cellNode.children = [ new MDTextNode(cellText) ];
- if (gridCell.isCalculated) {
- cellNode.cssClasses.push('calculated');
- }
- cellNode.cssClasses.push(`spreadsheet-type-${gridValue.type}`);
- if (gridValue.type == CellValue.TYPE_ERROR) {
- cellNode.attributes['title'] = gridValue.value;
- }
- const gridNumber = gridValue.numericValue();
- if (gridNumber !== null) {
- cellNode.attributes['data-numeric-value'] = `${gridNumber}`;
- }
- const gridString = gridValue.stringValue(false);
- if (gridString !== null) {
- cellNode.attributes['data-string-value'] = gridString;
- }
- }
- }
- }
- }
|