UNPKG

@jsreport/jsreport-xlsx

Version:

jsreport recipe rendering excels directly from open xml

1,535 lines (1,177 loc) 55.3 kB
/* eslint no-unused-vars: 0 */ function xlsxColAutofit (options) { if ( options?.data?.meta?.autofit?.enabledFor?.length > 0 && options.hash.all === true ) { options.data.meta.autofit.enabledFor = [true] } const enabledFor = options?.data?.meta?.autofit?.enabledFor ?? [] const allColsEnabled = enabledFor[0] === true if (!allColsEnabled) { const cols = options?.data?.meta?.autofit?.cols ?? {} for (const colLetter of Object.keys(cols)) { // remove cells that are not enabled for autofit if (!enabledFor.includes(colLetter)) { delete cols[colLetter] } } } return '' } function xlsxCType (options) { if (!Object.hasOwn(options.data, 'cellType')) { return '' } const type = options.hash.t if (type == null) { throw new Error('xlsxCType helper requires type parameter to be set') } const validTypes = ['s', 'b', 'n'] if (!validTypes.includes(type)) { throw new Error(`xlsxCType helper requires type parameter to be one of: ${validTypes.join(', ')}`) } options.data.cellType = type return '' } function xlsxChart (options) { const Handlebars = require('handlebars') if (options.hash.data == null) { throw new Error('xlsxChart helper requires data parameter to be set') } if (!Array.isArray(options.hash.data.labels) || options.hash.data.labels.length === 0) { throw new Error('xlsxChart helper requires data parameter with labels to be set, data.labels must be an array with items') } if (!Array.isArray(options.hash.data.datasets) || options.hash.data.datasets.length === 0) { throw new Error('xlsxChart helper requires data parameter with datasets to be set, data.datasets must be an array with items') } if ( options.hash.options != null && ( typeof options.hash.options !== 'object' || Array.isArray(options.hash.options) ) ) { throw new Error('xlsxChart helper when options parameter is set, it should be an object') } return new Handlebars.SafeString(`$xlsxChart${Buffer.from(JSON.stringify(options.hash)).toString('base64')}$`) } function xlsxContext (options) { const Handlebars = require('handlebars') let data if (options.hash.type === 'global') { data = Handlebars.createFrame(options.data) data.evalId = options.hash.evalId data.calcChainUpdatesMap = new Map() data.styleInfo = null data.styleFontSizeMap = new Map() } const context = {} if (data) { context.data = data } const result = options.fn(this, context) return result } const __xlsxD = (function () { function ws (options) { const Handlebars = require('handlebars') const newData = Handlebars.createFrame(options.data) const sheetId = options.hash.sheetId assertOk(sheetId != null, 'sheetId arg is required') newData.sheetId = sheetId const tasks = new Map() newData.tasks = { wait (key) { return tasks.get(key)?.promise }, add (key) { let taskExecution = tasks.get(key) if (taskExecution != null) { return [taskExecution.resolve, taskExecution.reject] } taskExecution = {} taskExecution.promise = new Promise((resolve, reject) => { taskExecution.resolve = resolve taskExecution.reject = reject }) tasks.set(key, taskExecution) return [taskExecution.resolve, taskExecution.reject] } } // init tasks newData.tasks.add('sd') newData.tasks.add('lazyFormulas') newData.meta = { calcChainCellRefsSet: null, autofit: { cols: {}, enabledFor: null }, mergeCells: [], trackedCells: null, nonExistingCellsByLoopHierarchyMap: null, updatedOriginalCells: {}, lazyFormulas: {}, lastCellRef: null } newData.loopItems = [] newData.evaluatedLoopsIds = [] newData.outOfLoopTemplates = Object.create(null) newData.cellTemplatesMap = new Map() return options.fn(this, { data: newData }) } function sd (options) { const [resolveTask, rejectTask] = options.data.tasks.add('sd') try { const Handlebars = require('handlebars') options.data.meta.calcChainCellRefsSet = new Set(options.hash.calcChainCellRefs != null ? options.hash.calcChainCellRefs.split(',') : []) const nonExistingCellRefs = options.hash.nonExistingCellRefs != null ? options.hash.nonExistingCellRefs.split(',') : [] const autofitEnabledFor = options.hash.autofit != null ? options.hash.autofit.split(',') : [] const autofitBaseCols = options.hash.autofitBCols != null ? options.hash.autofitBCols.split(',').map((entry) => { const [colLetter, size] = entry.split(':') return [colLetter, parseFloat(size)] }) : [] for (const [colLetter, size] of autofitBaseCols) { options.data.meta.autofit.cols[colLetter] = { size } } const trackedCells = {} const nonExistingCellsByLoopHierarchyMap = new Map() if (nonExistingCellRefs.length > 0) { for (const data of nonExistingCellRefs) { const parts = data.split('|') const ref = parts[0] let fromNonExistingLoopHierarchyId if (parts.length === 2) { fromNonExistingLoopHierarchyId = parts[1] } trackedCells[ref] = { first: ref, last: ref, count: 0 } if (fromNonExistingLoopHierarchyId == null) { continue } let collection if (nonExistingCellsByLoopHierarchyMap.has(fromNonExistingLoopHierarchyId)) { collection = nonExistingCellsByLoopHierarchyMap.get(fromNonExistingLoopHierarchyId) } else { collection = [] nonExistingCellsByLoopHierarchyMap.set(fromNonExistingLoopHierarchyId, collection) } const item = { ref, trackedCell: trackedCells[ref] } collection.push(item) // we set empty string here, just as a signal that this is going to be set // at runtime trackedCells[ref].currentLoopId = '' trackedCells[ref].fromNonExistingLoopHierarchyId = fromNonExistingLoopHierarchyId } } options.data.meta.autofit.enabledFor = autofitEnabledFor options.data.meta.trackedCells = trackedCells options.data.meta.nonExistingCellsByLoopHierarchyMap = nonExistingCellsByLoopHierarchyMap const result = options.fn(this, { data: options.data }) resolveTask() return result } catch (e) { rejectTask(e) throw e } } // this helper is async on purpose so it can wait for the sd helper to finish. // this is needed because the dimension tag appears before the sheetData tag and // we want to keep to logic for updating this node in handlebars async function dimension (options) { const originalCellRefRange = options.hash.o assertOk(originalCellRefRange != null, 'originalCellRefRange arg is required') await options.data.tasks.wait('sd') const refsParts = originalCellRefRange.split(':') if (refsParts.length === 1) { return refsParts[0] } const { parseCellRef } = require('cellUtils') const lastCellRef = options.data.meta.lastCellRef const parsedEndCellRef = parseCellRef(refsParts[1]) const parsedLastCellRef = parseCellRef(lastCellRef) let dimensionLetter = parsedEndCellRef.letter if (parsedLastCellRef.columnNumber > parsedEndCellRef.columnNumber) { dimensionLetter = parsedLastCellRef.letter } let dimensionRowNumber = parsedEndCellRef.rowNumber if (parsedLastCellRef.rowNumber > parsedEndCellRef.rowNumber) { dimensionRowNumber = parsedLastCellRef.rowNumber } return `${refsParts[0]}:${dimensionLetter}${dimensionRowNumber}` } function loop (...args) { let data let options if (args.length === 1) { options = args[0] } else { data = args[0] options = args[1] } const Handlebars = require('handlebars') const customCells = options.hash.cells const customCellsForColumns = options.hash.cellsT === 'columns' const start = options.hash.start const columnStart = options.hash.columnStart const end = options.hash.end const columnEnd = options.hash.columnEnd const hierarchyId = options.hash.hierarchyId const nonExistingCellsByLoopHierarchyMap = options.data.meta.nonExistingCellsByLoopHierarchyMap const newData = Handlebars.createFrame(options.data) const isVertical = Object.hasOwn(options.hash, 'vertical') assertOk(start != null, 'start arg is required') assertOk(columnStart != null, 'columnStart arg is required') assertOk(columnEnd != null, 'columnEnd arg is required') assertOk(hierarchyId != null, 'hierarchyId arg is required') let targetData = data if (Object.hasOwn(options.hash, 'cells')) { targetData = customCells if (customCells == null || customCells.length === 0) { newData.emptyCells = true } } else if (customCellsForColumns) { targetData = options.data.emptyCells === true ? [] : this } // for empty we create an array with one empty object, // this is needed because we want to preserve the original row if ( targetData == null || (Array.isArray(targetData) && targetData.length === 0) ) { targetData = [{}] } let type if (customCells) { type = 'row' } else if (isVertical || customCellsForColumns) { type = 'vertical' } else { type = end == null ? 'row' : 'block' } const loopItem = { type, id: null, hierarchyId, start, columnStart, end, columnEnd, length: targetData.length, parentLoopIndex: options.data.index, children: [], completed: false } if (type === 'vertical') { loopItem.rowNumber = options.data.r loopItem.trackedCells = new Map() } const parentLoopItem = getParentLoopItemByHierarchy(loopItem, newData.loopItems) let container if (parentLoopItem) { container = parentLoopItem.children } else { container = newData.loopItems } loopItem.id = `${parentLoopItem != null ? `${parentLoopItem.id}#` : ''}${container.length}` container.push(loopItem) newData.currentLoopId = loopItem.id newData.evaluatedLoopsIds.push(loopItem.id) const nonExistingCellsInCurrentLoop = nonExistingCellsByLoopHierarchyMap.get(hierarchyId) ?? [] // updating the currentLoopId for the non existing cells that are part of this loop for (const item of nonExistingCellsInCurrentLoop) { item.trackedCell.currentLoopId = loopItem.id } const dynamicCells = customCells || customCellsForColumns if (dynamicCells && !Array.isArray(targetData)) { throw new Error(`Invalid data to generate dynamic cells. data for ${customCells ? 'rows' : 'columns'} is not an array`) } const chunks = [] for (let i = 0; i < targetData.length; i++) { newData.index = i newData.key = i if (customCellsForColumns) { newData.firstColumn = i === 0 newData.lastColumn = i === targetData.length - 1 newData.columnIndex = i } else { newData.first = i === 0 newData.last = i === targetData.length - 1 } if (customCells) { newData.rowIndex = i } let dataForItem = targetData[i] if (dynamicCells && dataForItem != null && typeof dataForItem === 'object' && !Array.isArray(dataForItem)) { // when dynamic cells if data is plain object, use the value property dataForItem = dataForItem.value ?? '' } const addBlockParams = options.fn.blockParams != null && options.fn.blockParams > 0 const newOptions = { ...options, data: newData } if (addBlockParams) { // propagate the expected block params from the loop, this is going to be // used in cell templates newData.blockParams = [dataForItem, newData.key].slice(0, options.fn.blockParams) } chunks.push(options.fn(dataForItem, newOptions)) } const result = new Handlebars.SafeString(chunks.join('')) loopItem.completed = true return result } loop.dynamicParameters = true function outOfLoop (options) { const Handlebars = require('handlebars') const item = options.hash.item assertOk(item != null, 'item arg is required') options.data.outOfLoopTemplates[item] = (currentLoopId, currentIdx) => { const newData = Handlebars.createFrame(options.data) newData.currentLoopId = currentLoopId if (currentIdx != null) { newData.index = currentIdx } return options.fn(this, { data: newData }) } return new Handlebars.SafeString('') } function outOfLoopPlaceholder (options) { const Handlebars = require('handlebars') const item = options.hash.item assertOk(item != null, 'item arg is required') const outOfLoopTemplate = options.data.outOfLoopTemplates[item] assertOk(outOfLoopTemplate != null, 'outOfLoopItem was not found') const currentLoopId = options.data.currentLoopId assertOk(currentLoopId != null, 'currentLoopId not found') const currentIdx = options.data.index const output = outOfLoopTemplate(currentLoopId, currentIdx) return new Handlebars.SafeString(output) } function r (options) { const Handlebars = require('handlebars') const originalRowNumber = options.hash.o assertOk(originalRowNumber != null, 'originalRowNumber arg is required') const newData = Handlebars.createFrame(options.data) const { increment: rowIncrement, currentLoopIncrement: rowCurrentLoopIncrement, previousRootLoopIncrement: rowPreviousRootLoopIncrement, previousLoopIncrement: rowPreviousLoopIncrement } = getIncrementWithLoop( 'row', { loopId: newData.currentLoopId, loopIndex: options.data.index, evaluatedLoopsIds: newData.evaluatedLoopsIds, loopItems: newData.loopItems } ) newData.originalRowNumber = originalRowNumber newData.r = originalRowNumber + rowIncrement // only a value that represents the increment of previous loops defined before the cell newData.rowPreviousLoopIncrement = rowPreviousRootLoopIncrement // this is a value that represents all the executions of the current loop (considering nested loops too) newData.rowCurrentLoopIncrement = rowCurrentLoopIncrement + (rowPreviousLoopIncrement - rowPreviousRootLoopIncrement) newData.originalColumnLetter = null newData.originalCellRef = null newData.l = null newData.currentCellRef = null newData.columnPreviousLoopIncrement = null newData.columnCurrentLoopIncrement = null const result = options.fn(this, { data: newData }) return result } function c (info, options) { const Handlebars = require('handlebars') const originalRowNumber = options.data.originalRowNumber const rowNumber = options.data.r const trackedCells = options.data.meta.trackedCells const { type, originalCellLetter, calcChainUpdate } = info const generateCellTag = type === 'autodetect' assertOk(originalRowNumber != null, 'originalRowNumber needs to exists on internal data') assertOk(rowNumber != null, 'rowNumber needs to exists on internal data') assertOk(trackedCells != null, 'trackedCells needs to exists on internal data') const { parseCellRef, getNewCellLetter } = require('cellUtils') const originalCellRef = `${originalCellLetter}${originalRowNumber}` const { increment: columnIncrement, currentLoopIncrement: columnCurrentLoopIncrement, previousRootLoopIncrement: columnPreviousRootLoopIncrement, previousLoopIncrement: columnPreviousLoopIncrement } = getIncrementWithLoop( 'column', { loopId: options.data.currentLoopId, loopIndex: options.data.index, rowNumber: options.data.r, evaluatedLoopsIds: options.data.evaluatedLoopsIds, loopItems: options.data.loopItems } ) const cellLetter = getNewCellLetter( originalCellLetter, columnIncrement ) const updatedCellRef = `${cellLetter}${rowNumber}` // keeping the lastCellRef updated if (options.data.meta.lastCellRef == null) { options.data.meta.lastCellRef = updatedCellRef } else { const parsedLastCellRef = parseCellRef(options.data.meta.lastCellRef) const parsedUpdatedCellRef = parseCellRef(updatedCellRef) if ( (parsedUpdatedCellRef.rowNumber === parsedLastCellRef.rowNumber && parsedUpdatedCellRef.columnNumber > parsedLastCellRef.columnNumber) || (parsedUpdatedCellRef.rowNumber > parsedLastCellRef.rowNumber) ) { options.data.meta.lastCellRef = updatedCellRef } } let shouldUpdateOriginalCell // if we are in loop then don't add item to updatedOriginalCells if (options.data.currentLoopId != null) { shouldUpdateOriginalCell = false } else { shouldUpdateOriginalCell = originalCellRef !== updatedCellRef && options.data.meta.updatedOriginalCells[originalCellRef] == null } if (shouldUpdateOriginalCell) { // keeping a registry of the original cells that were updated options.data.meta.updatedOriginalCells[originalCellRef] = updatedCellRef } trackedCells[originalCellRef] = trackedCells[originalCellRef] || { first: null, last: null, count: 0 } if (options.data.currentLoopId != null) { const loopItem = getCurrentLoopItem(options.data.currentLoopId, options.data.loopItems) if (loopItem?.type === 'vertical') { const parsedUpdatedCellRef = parseCellRef(updatedCellRef) let item if (loopItem.trackedCells.has(originalCellRef)) { item = loopItem.trackedCells.get(originalCellRef) } else { item = new Map() loopItem.trackedCells.set(originalCellRef, item) } item.set(parsedUpdatedCellRef.letter, updatedCellRef) } trackedCells[originalCellRef].currentLoopId = options.data.currentLoopId } if (trackedCells[originalCellRef].first == null) { trackedCells[originalCellRef].first = updatedCellRef } trackedCells[originalCellRef].last = updatedCellRef trackedCells[originalCellRef].count += 1 if (calcChainUpdate) { const sheetId = options.data.sheetId const cellRefKey = `${sheetId}-${originalCellRef}` let calcChainUpdatesForCellRef = options.data.calcChainUpdatesMap.get(cellRefKey) if (calcChainUpdatesForCellRef == null) { calcChainUpdatesForCellRef = [] options.data.calcChainUpdatesMap.set(cellRefKey, calcChainUpdatesForCellRef) } calcChainUpdatesForCellRef.push(updatedCellRef) } options.data.originalColumnLetter = originalCellLetter options.data.originalCellRef = originalCellRef options.data.l = cellLetter options.data.currentCellRef = updatedCellRef // only a value that represents the increment of previous loops defined before the cell options.data.columnPreviousLoopIncrement = columnPreviousRootLoopIncrement // this is a value that represents all the executions of the current loop (considering nested loops too) options.data.columnCurrentLoopIncrement = columnCurrentLoopIncrement + (columnPreviousLoopIncrement - columnPreviousRootLoopIncrement) if (!generateCellTag) { return updatedCellRef } const { getTextContent } = require('cellUtils') const cellTemplateFn = options.data.cellTemplatesMap.get(originalCellRef) assertOk(cellTemplateFn != null, `template for cell "${originalCellRef}" not found`) const templateOptions = { ...options } if (options.data.blockParams) { templateOptions.blockParams = options.data.blockParams } options.data.cellValue = null options.data.cellType = null const cellRawValue = cellTemplateFn(this, templateOptions) let cellValue let cellType if (options.data.cellValue != null) { // there will be cellValue set if there was a cell to auto detect cellValue = options.data.cellValue } else { // otherwise we use the text content from the raw value cellValue = getTextContent(cellRawValue) } if (options.data.cellType != null) { cellType = options.data.cellType // if we got explicit cellType, try to parse the cell value // to the type specified if (cellType === 'inlineStr' && typeof cellValue !== 'string') { if (cellValue == null) { cellValue = '' } else { cellValue = cellValue.toString() } } else if (cellType === 'b' && typeof cellValue !== 'boolean') { if (cellValue == null) { cellValue = false } else if (cellValue === 'true' || cellValue === 'false') { cellValue = cellValue === 'true' } else { const asNumber = parseInt(cellValue, 10) if (isNaN(asNumber)) { cellValue = false } else { cellValue = cellValue !== 0 } } } else if (cellType === 'n' && typeof cellValue !== 'number') { if (cellValue == null) { cellValue = 0 } else { const asNumber = parseFloat(cellValue) if (isNaN(asNumber)) { cellValue = 0 } else { cellValue = asNumber } } } } const enabledForCol = options.data.meta.autofit.enabledFor[0] === true ? true : options.data.meta.autofit.enabledFor.includes(originalCellLetter) if (enabledForCol) { const { getPixelWidthOfValue, getFontSizeFromStyle } = require('cellUtils') const fontSize = getFontSizeFromStyle(options.hash.s, options.data.styleInfo, options.data.styleFontSizeMap) const colInfo = options.data.meta.autofit.cols[originalCellLetter] const size = getPixelWidthOfValue(cellValue, fontSize) if (colInfo == null) { options.data.meta.autofit.cols[originalCellLetter] = { size } } else if (size > colInfo.size) { options.data.meta.autofit.cols[originalCellLetter] = { size } } } const serializedAttrs = [] const attrsKeys = Object.keys(options.hash) if (!attrsKeys.includes('t')) { attrsKeys.push('t') } for (let idx = 0; idx < attrsKeys.length; idx++) { const key = attrsKeys[idx] let value if (key === 'r') { // ensure updated cellRef is part of cell xml value = updatedCellRef } else if (key === 't') { if (cellType != null) { value = cellType } else if (cellValue == null) { cellType = 'inlineStr' } else if ( typeof cellValue === 'boolean' || ( cellValue != null && typeof cellValue === 'object' && Object.prototype.toString.call(cellValue) === '[object Boolean]' ) ) { cellType = 'b' } else if ( typeof cellValue === 'number' || ( cellValue != null && typeof cellValue === 'object' && Object.prototype.toString.call(cellValue) === '[object Number]' ) ) { cellType = 'n' } else { cellType = 'inlineStr' } value = cellType } else { // keep rest of attrs value = options.hash[key] } serializedAttrs.push(`${key}="${value}"`) } let cellContent if (cellType === 'inlineStr') { cellContent = cellRawValue } else if (cellType === 'b') { cellContent = `<v>${cellValue ? '1' : '0'}</v>` } else if (cellType === 'n') { cellContent = `<v>${cellValue}</v>` } assertOk(cellContent != null, `cell type "${cellType}" not supported`) let cellOutput = `<c ${serializedAttrs.join(' ')}` if (cellValue == null || cellValue === '') { cellOutput += ' />' } else { cellOutput += `>${cellContent}</c>` } return new Handlebars.SafeString(cellOutput) } function cValue (...args) { let _value let options let shouldCallBlock = false if (args.length === 1) { shouldCallBlock = true options = args[0] } else { _value = args[0] options = args[1] } let value if (shouldCallBlock) { const thisUnwrapped = this != null && typeof this.valueOf === 'function' ? this.valueOf() : this value = options.fn(thisUnwrapped) } else { value = _value // if value is null we try to resolve it from helper, replicating the same // logic that handlebars does if (value === undefined && options.hash.n != null) { const Handlebars = require('handlebars') if (Handlebars.helpers[options.hash.n]) { value = Handlebars.helpers[options.hash.n]() } } } if (value != null && typeof value.valueOf === 'function') { // we do this because handlebars something wraps the primitive values with their // object counterparts, so we need to ensure that we get the primitive value for // conditions to work correctly value = value.valueOf() } options.data.cellValue = value return value } cValue.dynamicParameters = true // register cell templates function cTmpl (options) { const Handlebars = require('handlebars') const cellRef = options.hash.cellRef assertOk(cellRef != null, 'cellRef arg is required') options.data.cellTemplatesMap.set(cellRef, options.fn) return new Handlebars.SafeString('') } function mergeOrFormulaCell (type, options) { const Handlebars = require('handlebars') const columnLetter = options.data.l const rowNumber = options.data.r assertOk(rowNumber != null, 'rowNumber needs to exists on internal data') let output = '' if (type === 'mergeCell') { const originalCellRefRange = options.hash.o assertOk(originalCellRefRange != null, 'originalCellRefRange arg is required') const { evaluateCellRefsFromExpression, generateNewCellRefFrom, getNewCellLetter } = require('cellUtils') const { newValue } = evaluateCellRefsFromExpression(originalCellRefRange, (cellRefInfo) => { const isRange = cellRefInfo.type === 'rangeStart' || cellRefInfo.type === 'rangeEnd' assertOk(isRange, `cell ref expected to be a range. value: "${originalCellRefRange}`) const columnIncrement = cellRefInfo.type === 'rangeEnd' ? cellRefInfo.parsedRangeEnd.columnNumber - cellRefInfo.parsedRangeStart.columnNumber : 0 const newColumnLetter = getNewCellLetter(columnLetter, columnIncrement) const rowIncrement = cellRefInfo.type === 'rangeEnd' ? cellRefInfo.parsedRangeEnd.rowNumber - cellRefInfo.parsedRangeStart.rowNumber : 0 const newRowNumber = rowNumber + rowIncrement const newCellRef = generateNewCellRefFrom(cellRefInfo.parsed, { columnLetter: newColumnLetter, rowNumber: newRowNumber }) return newCellRef }) const mergeCell = { original: originalCellRefRange, value: newValue } options.data.meta.mergeCells.push(mergeCell) } else { const { parseCellRef, getNewFormula, decodeXML, encodeXML } = require('cellUtils') const currentCellRef = options.data.currentCellRef const trackedCells = options.data.meta.trackedCells const lazyFormulas = options.data.meta.lazyFormulas const originalCellRef = options.data.originalCellRef // formulas can contain characters that are encoded as part of the xlsx processing, // we ensure here that we decode the xml entities const originalFormula = options.hash.o != null ? decodeXML(options.hash.o) : null const rowPreviousLoopIncrement = options.data.rowPreviousLoopIncrement const rowCurrentLoopIncrement = options.data.rowCurrentLoopIncrement const columnPreviousLoopIncrement = options.data.columnPreviousLoopIncrement const columnCurrentLoopIncrement = options.data.columnCurrentLoopIncrement assertOk(currentCellRef != null, 'currentCellRef needs to exists on internal data') assertOk(trackedCells != null, 'trackedCells needs to exists on internal data') assertOk(lazyFormulas != null, 'lazyFormulas needs to exists on internal data') assertOk(originalCellRef != null, 'originalCellRef needs to exists on internal data') assertOk(originalFormula != null, 'originalFormula arg is required') assertOk(rowCurrentLoopIncrement != null, 'currentLoopIncrement needs to exists on internal data') assertOk(columnCurrentLoopIncrement != null, 'columnCurrentLoopIncrement needs to exists on internal data') const parsedOriginCellRef = parseCellRef(originalCellRef) const originCellIsFromLoop = options.data.currentLoopId != null const { lazyCellRefs = {}, formula: newFormula } = getNewFormula(originalFormula, parsedOriginCellRef, { type: 'normal', originCellIsFromLoop, rowPreviousLoopIncrement, rowCurrentLoopIncrement, columnPreviousLoopIncrement, columnCurrentLoopIncrement, trackedCells, getCurrentLoopItem: (currentLoopId) => { return getCurrentLoopItem(currentLoopId, options.data.loopItems) }, includeLoopIncrementResolver: (cellRefIsFromLoop, cellRefInfo) => { // this is used when referencing a cell that it is not defined in the sheet return ( cellRefIsFromLoop && trackedCells[cellRefInfo.localRef] != null && trackedCells[cellRefInfo.localRef].fromNonExistingLoopHierarchyId === getCurrentLoopItem(options.data.currentLoopId, options.data.loopItems)?.hierarchyId ) }, lazyFormulas, currentCellRef }) // ensure we encode just some basic xml entities, formula values does not need to // have the full xml entities escaped if (Object.keys(lazyCellRefs).length > 0) { return options.data.tasks.wait('lazyFormulas').then(() => { const finalFormula = lazyFormulas.data[newFormula].newFormula return encodeXML(finalFormula, 'basic') }) } output = encodeXML(newFormula, 'basic') } return new Handlebars.SafeString(output) } function mergeCells (options) { const Handlebars = require('handlebars') const newData = Handlebars.createFrame(options.data) const [resolveTask, rejectTask] = options.data.tasks.add('mergeCells') newData.mergeCellsCount = 0 newData.mergeCellsTemplatesMap = new Map() try { const result = options.fn(this, { data: newData }) resolveTask() return result } catch (e) { rejectTask(e) throw e } } async function mergeCellsCount (options) { await options.data.tasks.wait('mergeCells') return options.data.mergeCellsCount } function mergeCellsItems (options) { const Handlebars = require('handlebars') const targetItems = options.data.meta.mergeCells // run the body to fulfill the merge cells templates options.fn(this) const mergeCellsTemplatesMap = options.data.mergeCellsTemplatesMap let original = [] const generated = [] const originalOrderScore = new Map( Array.from(mergeCellsTemplatesMap.keys()).map((key, idx) => [key, idx]) ) for (const targetItem of targetItems) { const template = mergeCellsTemplatesMap.get(targetItem.original) const output = template({ newRef: targetItem.value }) if (targetItem.original === targetItem.value) { original.push({ score: originalOrderScore.get(targetItem.original), output }) } else { generated.push(output) } } // preserve the original order from xlsx file, so we avoid noise in diffs when // comparing xlsx output original.sort((a, b) => a.score - b.score) original = original.map((item) => item.output) options.data.mergeCellsCount = original.length + generated.length const output = `${original.join('\n')}${generated.join('\n')}` return new Handlebars.SafeString(output) } function mI (options) { const originalCellRefRange = options.hash.o assertOk(originalCellRefRange != null, 'originalCellRefRange arg is required') options.data.mergeCellsTemplatesMap.set(originalCellRefRange, options.fn) return '' } function formulaShared (options) { const columnLetter = options.data.l const rowNumber = options.data.r assertOk(rowNumber != null, 'rowNumber needs to exists on internal data') const originalSharedRefRange = options.hash.o assertOk(originalSharedRefRange != null, 'originalSharedRefRange arg is required') const { evaluateCellRefsFromExpression, generateNewCellRefFrom, getNewCellLetter } = require('cellUtils') const { newValue } = evaluateCellRefsFromExpression(originalSharedRefRange, (cellRefInfo) => { const isRange = cellRefInfo.type === 'rangeStart' || cellRefInfo.type === 'rangeEnd' assertOk(isRange, `cell ref expected to be a range. value: "${originalSharedRefRange}`) const columnIncrement = cellRefInfo.type === 'rangeEnd' ? cellRefInfo.parsedRangeEnd.columnNumber - cellRefInfo.parsedRangeStart.columnNumber : 0 const newColumnLetter = getNewCellLetter(columnLetter, columnIncrement) const rowIncrement = cellRefInfo.type === 'rangeEnd' ? cellRefInfo.parsedRangeEnd.rowNumber - cellRefInfo.parsedRangeStart.rowNumber : 0 const newRowNumber = rowNumber + rowIncrement const newCellRef = generateNewCellRefFrom(cellRefInfo.parsed, { columnLetter: newColumnLetter, rowNumber: newRowNumber }) return newCellRef }) return newValue } // TODO: this should be refactored at some point to be more generic // and support nested loops, maybe the logic will be similar to mergeCell or formula helpers // when this is done we can remove all the methods that are only used here "getNewCellRef", "getCurrentAndPreviousLoopItemsByTarget" function newCellRef (options) { const Handlebars = require('handlebars') const updatedOriginalCells = options.data.meta.updatedOriginalCells const loopItems = options.data.loopItems let targetItems = [] const updated = [] const type = 'newCellRef' if (type === 'newCellRef') { targetItems = [{ value: options.hash.originalCellRefRange }] } for (const targetItem of targetItems) { const regexp = /(\$?[A-Z]+\$?\d+:)?(\$?[A-Z]+\$?\d+)/g const newValue = targetItem.value.replace(regexp, (...args) => { const [match, _startingCellRef, endingCellRef] = args const isRange = _startingCellRef != null let newCellRef const ctx = { updatedOriginalCells, loopItems } if (isRange) { const startingCellRef = _startingCellRef.slice(0, -1) const newStartingCellRef = getNewCellRef(type === 'formulas' ? [targetItem.cellRef, startingCellRef] : startingCellRef, targetItem.loopMeta, 'rangeStart', ctx) const newEndingCellRef = getNewCellRef(type === 'formulas' ? [targetItem.cellRef, endingCellRef] : endingCellRef, targetItem.loopMeta, 'rangeEnd', ctx) return `${newStartingCellRef}:${newEndingCellRef}` } else { newCellRef = getNewCellRef(type === 'formulas' ? [targetItem.cellRef, endingCellRef] : endingCellRef, targetItem.loopMeta, 'standalone', ctx) } return newCellRef }) if (type === 'newCellRef') { updated.push(newValue) } } return new Handlebars.SafeString(updated.join('\n')) } async function autofit (options) { const Handlebars = require('handlebars') const processAutofitCols = require('xlsxProcessAutofitCols') const existingColsXml = options.fn(this) await options.data.tasks.wait('sd') return new Handlebars.SafeString(processAutofitCols(options.data.meta.autofit, existingColsXml)) } function lazyFormulas (options) { const [resolveTask, rejectTask] = options.data.tasks.add('lazyFormulas') try { const trackedCells = options.data.meta.trackedCells const lazyFormulas = options.data.meta.lazyFormulas assertOk(trackedCells != null, 'trackedCells needs to exists on internal data') assertOk(lazyFormulas != null, 'lazyFormulas needs to exists on internal data') if (lazyFormulas.count == null || lazyFormulas.count === 0) { resolveTask() return '' } const lazyFormulaIds = Object.keys(lazyFormulas.data) const { getNewFormula } = require('cellUtils') for (const lazyFormulaId of lazyFormulaIds) { const lazyFormulaInfo = lazyFormulas.data[lazyFormulaId] const { formula, parsedOriginCellRef, originCellIsFromLoop, rowPreviousLoopIncrement, rowCurrentLoopIncrement, columnPreviousLoopIncrement, columnCurrentLoopIncrement, cellRefs } = lazyFormulaInfo const { formula: newFormula } = getNewFormula(formula, parsedOriginCellRef, { type: 'lazy', originCellIsFromLoop, rowPreviousLoopIncrement, rowCurrentLoopIncrement, columnPreviousLoopIncrement, columnCurrentLoopIncrement, trackedCells, getCurrentLoopItem: (currentLoopId) => { return getCurrentLoopItem(currentLoopId, options.data.loopItems) }, lazyCellRefs: cellRefs }) // ensure we encode just some basic xml entities, formula values does not need to // have the full xml entities escaped lazyFormulaInfo.newFormula = newFormula } resolveTask() return '' } catch (e) { rejectTask(e) throw e } } function style (options) { const Handlebars = require('handlebars') const { parseStyle, getStyleInfo } = require('xlsxProcessStyle') const styleXml = options.fn(this) const styleDoc = parseStyle(styleXml) const styleInfo = getStyleInfo(styleDoc) if (styleInfo != null) { options.data.styleInfo = styleInfo } return new Handlebars.SafeString(styleXml) } function calcChain (options) { const Handlebars = require('handlebars') const processCalcChain = require('xlsxProcessCalcChain') const existingCalcChainXml = options.fn(this) return new Handlebars.SafeString(processCalcChain(options.data.calcChainUpdatesMap, existingCalcChainXml)) } function raw (options) { return options.fn() } function getIncrementWithLoop (mode, { loopId, loopIndex, rowNumber, evaluatedLoopsIds, loopItems }) { if (mode !== 'row' && mode !== 'column') { throw new Error('mode must be "row" or "column"') } if (mode === 'column' && rowNumber == null) { throw new Error('rowNumber must be specified when using column mode') } const currentLoopItem = getCurrentLoopItem(loopId, loopItems) // this gets the previous loops (loops defined before a cell) and also on the case of nested loops // all the previous executions of the current loop const previousLoopItems = getPreviousLoopItems(loopId, evaluatedLoopsIds, loopItems) const previousMeta = { prev: { total: 0, loopLength: 0 }, rest: { total: 0, loopLength: 0 } } const currentRootLoopIdNum = loopId != null ? parseInt(loopId.split('#')[0], 10) : -1 let currentLoopIncrement = 0 const validForColumnMode = ['vertical'] const validForRowMode = ['row', 'block'] const isValid = (cLoop) => { return mode === 'column' ? validForColumnMode.includes(cLoop.type) : validForRowMode.includes(cLoop.type) } const isValidPrevious = (cLoop, cRowNumber) => { const valid = isValid(cLoop) if (valid) { return mode === 'column' ? cLoop.rowNumber === cRowNumber : true } return valid } const isBlock = (cLoop) => mode === 'column' ? false : cLoop.type === 'block' for (const item of previousLoopItems) { const previousRootLoopIdNum = parseInt(item.id.split('#')[0], 10) const isPrev = currentRootLoopIdNum === -1 ? true : previousRootLoopIdNum < currentRootLoopIdNum let loopItemsLength = 0 const target = isPrev ? previousMeta.prev : previousMeta.rest if (isValidPrevious(item, rowNumber)) { if (isBlock(item)) { loopItemsLength += getLoopItemTemplateLength(item) * (item.length - 1) } else { loopItemsLength += item.length target.loopLength += 1 } } target.total += loopItemsLength } const previousRootLoopIncrement = previousMeta.prev.total + (previousMeta.prev.loopLength > 0 ? previousMeta.prev.loopLength * -1 : 0) const previousLoopIncrement = previousRootLoopIncrement + previousMeta.rest.total + (previousMeta.rest.loopLength > 0 ? previousMeta.rest.loopLength * -1 : 0) if (currentLoopItem) { assertOk(loopIndex != null, 'expected loop index to be defined') const parents = getParentsLoopItems(currentLoopItem.id, loopItems) let parentLoopIndex = currentLoopItem.parentLoopIndex parents.reverse() for (const parentLoopItem of parents) { if (isValid(parentLoopItem)) { currentLoopIncrement += getLoopItemTemplateLength(parentLoopItem) * parentLoopIndex } parentLoopIndex = parentLoopItem.parentLoopIndex } if (isValid(currentLoopItem)) { const templateLength = getLoopItemTemplateLength(currentLoopItem) currentLoopIncrement = currentLoopIncrement + (templateLength * loopIndex) } } const increment = previousLoopIncrement + currentLoopIncrement return { increment, currentLoopIncrement, previousRootLoopIncrement, previousLoopIncrement } } function getParentLoopItemByHierarchy (childLoopItem, loopItems) { assertOk(childLoopItem != null, 'getParentLoopItemByHierarchy childLoopItem arg is required') assertOk(Array.isArray(loopItems), 'getParentLoopItemByHierarchy loopItems arg is invalid') const parentHierarchyId = childLoopItem.hierarchyId.split('#').slice(0, -1).join('#') if (parentHierarchyId === '') { return } return getLoopItemById({ idName: 'hierarchyId', idValue: parentHierarchyId }, loopItems) } function getCurrentLoopItem (loopId, loopItems) { assertOk(Array.isArray(loopItems), 'getCurrentLoopItem loopItems arg is invalid') if (loopId == null) { return } return getLoopItemById({ idName: 'id', idValue: loopId }, loopItems) } function getPreviousLoopItems (loopId, evaluatedLoopsIds, loopItems) { assertOk(Array.isArray(evaluatedLoopsIds), 'getPreviousLoopItems evaluatedLoopsIds arg is invalid') assertOk(Array.isArray(loopItems), 'getPreviousLoopItems loopItems arg is invalid') const lastEvaluatedLoopId = evaluatedLoopsIds[evaluatedLoopsIds.length - 1] const loopItemsToGet = loopId != null && loopId === lastEvaluatedLoopId ? evaluatedLoopsIds.slice(0, -1) : evaluatedLoopsIds const result = [] for (const lId of loopItemsToGet) { const loopItem = getLoopItemById({ idName: 'id', idValue: lId }, loopItems) assertOk(loopItem != null, `Can not find loop item by id "${lId}"`) if (!loopItem.completed) { continue } result.push(loopItem) } return result } function getCurrentAndPreviousLoopItemsByTarget (byTarget, loopItems) { assertOk(byTarget != null, 'getCurrentAndPreviousLoopItemsByTarget byTarget arg is invalid') assertOk(byTarget.rowNumber != null, 'getCurrentAndPreviousLoopItemsByTarget byTarget.rowNumber arg is required') assertOk(byTarget.columnNumber != null, 'getCurrentAndPreviousLoopItemsByTarget byTarget.columnNumber arg is required') assertOk(Array.isArray(loopItems), 'getCurrentAndPreviousLoopItemsByTarget loopItems arg is invalid') const { rowNumber, columnNumber } = byTarget const matchedLoopItems = loopItems.filter((item) => { assertOk(item.completed, 'getCurrentAndPreviousLoopItemsByTarget invalid usage, it should be called only after all loop items are completed evaluated') return item.start <= rowNumber }) let current const previousAll = [...matchedLoopItems] const targetLoopItem = previousAll[previousAll.length - 1] const previous = previousAll.slice(0, -1) if (targetLoopItem != null) { let isInside = false const limit = targetLoopItem.type === 'block' ? targetLoopItem.end : targetLoopItem.start if (rowNumber === limit) { // for row loops we assume the row is inside when the row just matches the limit // (even if technically on the out of loop right case we should check columnEnd, // we don't do that because in that case the cell will anyway keep on its original place) isInside = targetLoopItem.type === 'block' ? targetLoopItem.columnEnd > columnNumber : true } else { isInside = limit > rowNumber } if (!isInside) { previous.push(targetLoopItem) } else { current = targetLoopItem } } return { current, previousAll, previous } } function getLoopItemTemplateLength (loopItem) { assertOk(loopItem != null, 'getLoopItemTemplateLength loopItem arg is invalid') let templateLength = 1 if (loopItem.type === 'block') { templateLength = (loopItem.end - loopItem.start) + 1 } return templateLength } function getParentsLoopItems (loopId, loopItems) { assertOk(loopId != null, 'getParentsLoopItems loopId arg is invalid') assertOk(Array.isArray(loopItems), 'getParentsLoopItems loopItems arg is invalid') const results = [] const parentIdParts = loopId.split('#').slice(0, -1) if (parentIdParts.length === 0) { return results } let parentId = '' for (let index = 0; index < parentIdParts.length; index++) { parentId += parentId === '' ? parentIdParts[index] : `#${parentIdParts[index]}` const result = getLoopItemById({ idName: 'id', idValue: parentId }, loopItems) assertOk(result != null, `Can not find loop item by id "${parentId}"`) results.push(result) } return results } function getLoopItemById (byTarget, loopItems) { assertOk(byTarget != null, 'getLoopItemById byTarget arg is required') assertOk(Array.isArray(loopItems), 'getLoopItemById loopItems arg is invalid') const { idName, idValue } = byTarget assertOk(idName != null, 'getLoopItemById byTarget.idName arg is required') assertOk(typeof idName === 'string', 'getLoopItemById byTarget.idName arg is invalid') assertOk(idName === 'hierarchyId' || idName === 'id', 'getLoopItemById byTarget.idName should be either "hierarchyId" or "id"') assertOk(idValue != null, 'getLoopItemById byTarget.idValue arg is required') assertOk(typeof idValue === 'string', 'getLoopItemById byTarget.idValue arg is invalid') const idParts = idValue.split('#') let ctx = { children: loopItems } let targetIdValue = '' let parent while (idParts.length > 0) { const idx = idParts.shift() targetIdValue = targetIdValue !== '' ? `${targetIdValue}#${idx}` : `${idx}` const matches = ctx.children.filter((c) => c[idName] === targetIdValue) const result = matches[matches.length - 1] if (result == null) { break } ctx = result if (idParts.length === 0) { parent = ctx } } return parent } function getNewCellRef (cellRefInput, originLoopMeta, mode = 'standalone', context) { const type = 'newCellRef' const { updatedOriginalCells, loopItems } = context let cellRef let originCellRef if (Array.isArray(cellRefInput)) { originCellRef = cellRefInput[0] cellRef = cellRefInput[1] } else { cellRef = cellRefInput } const { parseCellRef } = require('cellUtils') const parsedCellRef = parseCellRef(cellRef) const parsedOriginCellRef = originCellRef != null ? parseCellRef(originCellRef) : undefined const normalizedCellRef = cellRef.replace('$', '') let newCellRef = updatedOriginalCells[normalizedCellRef] let currentLoopItem if (newCellRef == null) { // if not found on original cells then do a check if we find // matched loop items for the referenced row numbers const { current: currentLoopItemForTarget, previousAll: previousAllLoopItemsForTarget, previous: previousLoopItemsForTarget } = getCurrentAndPreviousLoopItemsByTarget({ rowNumber: parsedCellRef.rowNumber, columnNumber: parsedCellRef.columnNumber }, loopItems) currentLoopItem = currentLoopItemForTarget if (currentLoopItemForTarget != null || previousLoopItemsForTarget.length > 0) { const originIsLoopItem = parsedOriginCellRef == null ? false : getCurrentAndPreviousLoopItemsByTarget({ rowNumber: parsedOriginCellRef.rowNumber, columnNumber: parsedOriginCellRef.columnNumber }, loopItems).current != null const getAfterIncrement = (parsedC, all = false) => { const filteredLoopItems = all ? previousAllLoopItemsForTarget : previousLoopItemsForTarget const rowMatchedLoopItems = [] let increment = filteredLoopItems.reduce((acu, i