UNPKG

@jsreport/jsreport-xlsx

Version:

jsreport recipe rendering excels directly from open xml

1,323 lines (1,038 loc) 59.1 kB
const path = require('path') const { num2col } = require('xlsx-coordinates') const { nodeListToArray, isWorksheetFile, isWorksheetRelsFile, getSheetInfo, getCellInfo, getStyleFile, getStyleInfo } = require('../../utils') const { parseCellRef, getPixelWidthOfValue, getFontSizeFromStyle, evaluateCellRefsFromExpression } = require('../../cellUtils') const startLoopRegexp = /{{#each\s+([^{|}]{0,500})(?:\s*(as\s+\|\w+\|))?\s*}}/ module.exports = (files, ctx) => { const workbookDoc = files.find((file) => file.path === 'xl/workbook.xml')?.doc const workbookRelsDoc = files.find((file) => file.path === 'xl/_rels/workbook.xml.rels')?.doc const sharedStringsDoc = files.find((f) => f.path === 'xl/sharedStrings.xml')?.doc const calcChainDoc = files.find((f) => f.path === 'xl/calcChain.xml')?.doc const styleInfo = getStyleInfo(getStyleFile(files)?.doc) const workbookCalcPrEl = workbookDoc.getElementsByTagName('calcPr')[0] let workbookSheetsEls = [] let workbookRelsEls = [] let sharedStringsEls = [] const calcChainMap = new Map() if (workbookDoc) { workbookSheetsEls = nodeListToArray(workbookDoc.getElementsByTagName('sheet')) } if (workbookRelsDoc != null) { workbookRelsEls = nodeListToArray(workbookRelsDoc.getElementsByTagName('Relationship')) } if (sharedStringsDoc != null) { sharedStringsEls = nodeListToArray(sharedStringsDoc.getElementsByTagName('si')) } if (calcChainDoc != null) { const calcChainCellEls = nodeListToArray(calcChainDoc.getElementsByTagName('c')) // we store the existing cell ref into other attribute // because later the attribute that contains the cell ref // is going to be updated for (const calcChainEl of calcChainCellEls) { calcChainMap.set(`${calcChainEl.getAttribute('i')}-${calcChainEl.getAttribute('r')}`, calcChainEl) } } if (workbookCalcPrEl != null) { // set that this workbook should perform a full // recalculation when the workbook is opened workbookCalcPrEl.setAttribute('fullCalcOnLoad', '1') } for (const f of files.filter((f) => isWorksheetFile(f.path))) { const sheetFilepath = f.path const sheetFilename = path.posix.basename(sheetFilepath) const sheetDoc = f.doc const sheetDataEl = sheetDoc.getElementsByTagName('sheetData')[0] let colsEl = sheetDoc.getElementsByTagName('cols')[0] if (sheetDataEl == null) { throw new Error(`Could not find sheet data for sheet at ${sheetFilepath}`) } const sheetInfo = getSheetInfo(sheetFilepath, workbookSheetsEls, workbookRelsEls) if (sheetInfo == null) { throw new Error(`Could not find sheet info for sheet at ${sheetFilepath}`) } const sheetRelsDoc = files.find((file) => isWorksheetRelsFile(sheetFilename, file.path))?.doc const rowsEls = nodeListToArray(sheetDataEl.getElementsByTagName('row')) // looking for comments in the sheet const resultAutofitConfigured = findAutofitConfigured(sheetFilepath, sheetDoc, sheetRelsDoc, files) const isAutofitConfigured = resultAutofitConfigured.length > 0 const autoFitColLettersStr = resultAutofitConfigured.map((r) => num2col(r.column)).join(',') // wrap the <sheetData> into wrapper so we can store data during helper calls const sheetDataCallProps = {} if (isAutofitConfigured) { sheetDataCallProps.autofit = autoFitColLettersStr if (colsEl == null) { colsEl = sheetDoc.createElement('cols') sheetDataEl.parentNode.insertBefore(colsEl, sheetDataEl) } if (ctx.autofitConfigured !== true) { ctx.autofitConfigured = true } } const sheetDataBlockStartEl = processOpeningTag(sheetDoc, sheetDataEl, getDataHelperCall('sd', sheetDataCallProps)) let sheetDataEdgeEl = sheetDataEl while (sheetDataEdgeEl.nextSibling != null) { const nextSibling = sheetDataEdgeEl.nextSibling if (nextSibling.nodeName === 'mergeCells') { sheetDataEdgeEl = nextSibling break } else { sheetDataEdgeEl = nextSibling } } sheetDataEdgeEl = sheetDataEdgeEl.nodeName === 'mergeCells' ? sheetDataEdgeEl : sheetDataEl processClosingTag(sheetDoc, sheetDataEdgeEl, '{{/_D}}') const mergeCellsEl = sheetDoc.getElementsByTagName('mergeCells')[0] const mergeCellEls = mergeCellsEl == null ? [] : nodeListToArray(mergeCellsEl.getElementsByTagName('mergeCell')) const dimensionEl = sheetDoc.getElementsByTagName('dimension')[0] if (dimensionEl != null && rowsEls.length > 0) { // if sheetData has rows we add the dimension tag into the sheetData to be able to update // the ref by the handlebars dimensionEl.setAttribute('ref', `{{_D t='dimension' o='${dimensionEl.getAttribute('ref')}'}}`) } if (sheetRelsDoc != null) { const relationshipEls = nodeListToArray(sheetRelsDoc.getElementsByTagName('Relationship')) const tableRelEls = relationshipEls.filter((rel) => rel.getAttribute('Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table') if (tableRelEls.length > 0) { const newTablesUpdatedEl = sheetDoc.createElement('tablesUpdated') for (const tableRelEl of tableRelEls) { const newTableUpdatedEl = sheetDoc.createElement('tableUpdated') const tablePath = path.posix.join(path.posix.dirname(sheetFilepath), tableRelEl.getAttribute('Target')) newTableUpdatedEl.setAttribute('file', tablePath) const tableDoc = files.find((file) => file.path === tablePath)?.doc if (tableDoc == null) { throw new Error(`Could not find table definition info for sheet at ${sheetFilepath}`) } newTableUpdatedEl.setAttribute('ref', `{{_D t='newCellRef' originalCellRefRange='${tableDoc.documentElement.getAttribute('ref')}'}}`) const autoFilterEl = tableDoc.getElementsByTagName('autoFilter')[0] if (autoFilterEl != null) { const newAutoFilterRef = sheetDoc.createElement('autoFilterRef') newAutoFilterRef.setAttribute('ref', `{{_D t='newCellRef' originalCellRefRange='${autoFilterEl.getAttribute('ref')}'}}`) newTableUpdatedEl.appendChild(newAutoFilterRef) } newTablesUpdatedEl.appendChild(newTableUpdatedEl) } sheetDataEl.appendChild(newTablesUpdatedEl) } } if (isAutofitConfigured) { for (const conf of resultAutofitConfigured) { const tEls = nodeListToArray(conf.commentEl.getElementsByTagName('t')) let shouldRemoveComment = false const tCallEl = tEls.find((tEl) => tEl.textContent.startsWith('{{xlsxColAutofit')) if (conf.row === 0 && conf.column === 0 && tCallEl != null) { processOpeningTag(sheetDoc, sheetDataEl.firstChild, tCallEl.textContent) } if (tEls.length === 1) { const expectedRegexp = /^{{xlsxColAutofit( [^}]*)?}}$/ shouldRemoveComment = expectedRegexp.test(tEls[0].textContent) } else if (tEls[0].textContent.endsWith(':')) { const remainingText = tEls.slice(1).map((el) => el.textContent).join('') const expectedRegexp = /^\r?\n?{{xlsxColAutofit( [^}]*)?}}$/ shouldRemoveComment = expectedRegexp.test(remainingText) } if (shouldRemoveComment) { conf.commentEl.parentNode.removeChild(conf.commentEl) conf.shapeEl.parentNode.removeChild(conf.shapeEl) } } } const previousRows = [] const loopsDetected = [] const outOfLoopElsToHandle = [] const mergeCellElsToHandle = [] const formulaCellElsToHandle = [] const cellsByRowMap = new Map() const cellsElsByRefMap = new Map() const cellTemplatesMap = new Map() const colMaxSizeMap = new Map() const staticCellElsToHandle = [] const contentDetectCellElsToHandle = [] const lastRowIdx = rowsEls.length - 1 for (const [rowIdx, rowEl] of rowsEls.entries()) { let originalRowNumber = rowEl.getAttribute('r') const isLastRow = rowIdx === lastRowIdx if (originalRowNumber == null || originalRowNumber === '') { throw new Error('Expected row to contain r attribute defined') } originalRowNumber = parseInt(originalRowNumber, 10) // wrap the <row> into wrapper so we can store data during helper calls processOpeningTag(sheetDoc, rowEl, `{{#_R ${originalRowNumber}}}`) processClosingTag(sheetDoc, rowEl, '{{/_R}}') // update the row number to be based on helper call rowEl.setAttribute('r', '{{@r}}') const cellsEls = nodeListToArray(rowEl.getElementsByTagName('c')) if (cellsEls.length === 0) { // there can be cases when the row has no cells but it has merge cell defined, if yes // then queue it to process it later const foundMergeCellEls = mergeCellEls.filter((mergeCellEl) => { const ref = mergeCellEl.getAttribute('ref') const mergeStartCellRef = ref.split(':')[0] return parseCellRef(mergeStartCellRef).rowNumber === originalRowNumber }) for (const mergeCellEl of foundMergeCellEls) { mergeCellElsToHandle.push({ ref: mergeCellEl.getAttribute('ref'), rowEl }) } } for (const cellEl of cellsEls) { const cellRef = cellEl.getAttribute('r') const parsedCellRef = parseCellRef(cellRef) const cellMeta = { letter: parsedCellRef.letter, columnNumber: parsedCellRef.columnNumber } cellsElsByRefMap.set(cellRef, [cellEl, cellMeta]) if (!cellsByRowMap.has(originalRowNumber)) { cellsByRowMap.set(originalRowNumber, []) } cellsByRowMap.get(originalRowNumber).push(cellRef) let cellCallType = '_C' // search if we need to update some calc cell const calcCellEl = calcChainMap.get(`${sheetInfo.id}-${cellRef}`) if (calcCellEl != null) { cellCallType = '_c' cellMeta.calcChainUpdate = true } // using alias for helper to optimize the size of generated xml cellEl.setAttribute('r', `{{${cellCallType} '${cellMeta.letter}'}}`) // check if the cell starts a merge cell, if yes // then queue it to process it later const mergeCellEl = mergeCellEls.find((mergeCellEl) => { const ref = mergeCellEl.getAttribute('ref') return ref.startsWith(`${cellRef}:`) }) if (mergeCellEl != null) { mergeCellElsToHandle.push({ ref: mergeCellEl.getAttribute('ref'), rowEl }) } const info = getCellInfo(cellEl, sharedStringsEls, sheetFilepath) if ( info != null && (info.type === 'inlineStr' || info.type === 's') ) { const loopStartOrEndRegExp = /{{[#/]each/ const loopCallsInCell = [] let remainingToCheck = info.value let lastProcessedIdx let lastStartCall = '' const normalizeIfVerticalLoop = (cLoop, callStr) => { if (cLoop?.end == null && callStr !== '') { const loopHelperCall = callStr.match(startLoopRegexp)[0] const isVertical = cLoop.type === 'block' && loopHelperCall != null && loopHelperCall.includes('vertical=') if (isVertical) { cLoop.type = 'vertical' } } } do { const match = remainingToCheck.match(loopStartOrEndRegExp) let currentLoopDetected = getLatestNotClosedLoop(loopsDetected) if (match != null) { const partType = match[0] === '{{#each' ? 'start' : 'end' let inlineLoop = false let newLoopItem if (currentLoopDetected != null && partType === 'end') { // if loop starts and end in same cell and it is not dynamic then // we don't consider it a loop for our purposes // (it is just a normal loop that creates strings not rows/cells) if (currentLoopDetected.type !== 'dynamic' && currentLoopDetected.start.el === cellEl) { inlineLoop = true loopsDetected.pop() } else { if (currentLoopDetected.type === 'vertical') { let foundMatchingVerticalLoop = false const latestNotClosedLoops = getLatestNotClosedLoop(loopsDetected, true) for (const latestLoop of latestNotClosedLoops) { if (latestLoop.type === 'vertical' && parsedCellRef.columnNumber === latestLoop.start.originalColumnNumber) { foundMatchingVerticalLoop = latestLoop break } } if (!foundMatchingVerticalLoop) { throw new Error(`Unable to match start {{#each}} and end {{/each}} of vertical loop for multiple rows in ${f.path}. both start and end of loop must be on same column`) } currentLoopDetected = foundMatchingVerticalLoop } currentLoopDetected.end = { el: cellEl, cellRef, info, originalRowNumber, originalColumnNumber: parsedCellRef.columnNumber } if ( currentLoopDetected.type === 'block' && currentLoopDetected.end.originalRowNumber === currentLoopDetected.start.originalRowNumber ) { currentLoopDetected.type = 'row' } } } else if (partType === 'start') { normalizeIfVerticalLoop(currentLoopDetected, lastStartCall) const isNested = currentLoopDetected != null && currentLoopDetected.type !== 'vertical' const hierarchyIdPrefix = isNested ? `${currentLoopDetected.hierarchyId}#` : '' const hierarchyIdCounter = isNested ? currentLoopDetected.children.length : loopsDetected.length const hierarchyId = `${hierarchyIdPrefix}${hierarchyIdCounter}` newLoopItem = { type: 'block', hierarchyId, blockStartEl: null, blockEndEl: null, children: [], start: { el: cellEl, cellRef, info, originalRowNumber, originalColumnNumber: parsedCellRef.columnNumber } } if (isNested) { currentLoopDetected.children.push(newLoopItem) } loopsDetected.push(newLoopItem) } const endLoopPartIdx = remainingToCheck.indexOf('}}', match.index + match[0].length) let lastPartIdx if (endLoopPartIdx !== -1) { lastPartIdx = endLoopPartIdx + 2 } else { lastPartIdx = match.index + match[0].length } if (partType === 'start') { lastStartCall = remainingToCheck.slice(match.index, lastPartIdx) newLoopItem.start.helperCall = lastStartCall newLoopItem.start.helperCallStartIdx = (lastProcessedIdx ?? 0) + match.index if (lastStartCall.includes('cells=')) { newLoopItem.type = 'dynamic' } loopCallsInCell.push(newLoopItem) } else if (partType === 'end' && currentLoopDetected != null && !inlineLoop) { currentLoopDetected.end.helperCall = remainingToCheck.slice(match.index, lastPartIdx) currentLoopDetected.end.helperCallStartIdx = (lastProcessedIdx ?? 0) + match.index loopCallsInCell.push(currentLoopDetected) } lastProcessedIdx = (lastProcessedIdx ?? 0) + lastPartIdx remainingToCheck = remainingToCheck.slice(lastPartIdx) } else { remainingToCheck = '' normalizeIfVerticalLoop(currentLoopDetected, lastStartCall) } } while (remainingToCheck !== '') // only do content detection for the cells with handlebars if (info.value.includes('{{') && info.value.includes('}}')) { contentDetectCellElsToHandle.push({ cellRef, loopCallsInCell, textDetails: info.extra.textDetails }) } else if (isAutofitConfigured) { staticCellElsToHandle.push(cellRef) } } else if ( info != null && info.type === 'str' ) { // if cell was error but detected as formula // we updated to formula if (cellEl.getAttribute('t') === 'e') { cellEl.setAttribute('t', info.type) } const isSharedFormula = ( info.extra.formulaEl.getAttribute('t') === 'shared' && info.extra.formulaEl.getAttribute('si') != null && info.extra.formulaEl.getAttribute('si') !== '' ) const formulaInfo = { formula: info.value, formulaEl: info.extra.formulaEl } if (isSharedFormula) { const ref = info.extra.formulaEl.getAttribute('ref') formulaInfo.sharedFormula = { type: ref != null && ref !== '' ? 'source' : 'reference' } if (formulaInfo.sharedFormula.type === 'source') { formulaInfo.sharedFormula.sourceRef = info.extra.formulaEl.getAttribute('ref') } } const { cellRefs } = evaluateCellRefsFromExpression(info.value) formulaInfo.cellRefsInFormula = cellRefs formulaCellElsToHandle.push(formulaInfo) } } const loopsToProcess = checkAndGetLoopsToProcess(f, loopsDetected, originalRowNumber, isLastRow) for (const currentLoop of loopsToProcess) { const startingRowEl = currentLoop.start.el.parentNode const endingRowEl = currentLoop.end.el.parentNode const loopHelperCall = currentLoop.start.helperCall const isRowBased = currentLoop.type === 'row' || currentLoop.type === 'block' || currentLoop.type === 'dynamic' const outOfLoopTypes = [] const previousEls = getCellElsAndWrappersFrom(currentLoop.start.el, 'previous') const nextEls = getCellElsAndWrappersFrom(currentLoop.end.el, 'next') if (isRowBased && previousEls.length > 0) { // specify that there are cells to preserve that are before the each outOfLoopTypes.push('left') } if (isRowBased && nextEls.length > 0) { // specify that there are cells to preserve that are after the each outOfLoopTypes.push('right') } if (outOfLoopTypes.length > 0) { outOfLoopElsToHandle.push({ loopDetected: currentLoop, startingRowEl, endingRowEl, types: outOfLoopTypes }) } const parsedLoopStart = parseCellRef(currentLoop.start.cellRef) const parsedLoopEnd = parseCellRef(currentLoop.end.cellRef) const replaceLoopMatch = (match, dataExpressionPart, asPart) => { const targetDataExpressionPart = dataExpressionPart.trimEnd() const targetAsPart = asPart != null && asPart !== '' ? asPart.trim() : '' const extraAttrs = [ `hierarchyId='${currentLoop.hierarchyId}'`, `start=${currentLoop.start.originalRowNumber}` ] if (currentLoop.type === 'block') { extraAttrs.push(`end=${currentLoop.end.originalRowNumber}`) } extraAttrs.push( `columnStart=${parsedLoopStart.columnNumber}`, `columnEnd=${parsedLoopEnd.columnNumber}` ) if (targetAsPart !== '') { extraAttrs.push(targetAsPart) } return `{{#_D ${targetDataExpressionPart} t='loop' ${extraAttrs.join(' ')}}}` } if (currentLoop.type === 'dynamic') { const invalidLoop = loopsDetected.find((loop) => { if (loop.hierarchyId === currentLoop.hierarchyId) { return false } if (loop.type === 'vertical' || loop.type === 'dynamic') { // we are fine detecting just one side return ( loop.start.originalColumnNumber === parsedLoopStart.columnNumber && loop.start.originalRowNumber >= parsedLoopStart.rowNumber && loop.start.originalRowNumber <= parsedLoopEnd.rowNumber ) } else if (loop.type === 'row' || loop.type === 'block') { // we are fine detecting just one side in the case of block loops return ( loop.start.originalRowNumber >= parsedLoopStart.rowNumber && loop.start.originalRowNumber <= parsedLoopEnd.rowNumber ) } return false }) if (invalidLoop != null) { if (invalidLoop.type === 'dynamic') { throw new Error(`Dynamic cells can not have other dynamic cells defined in the same cell. Check dynamic cell definition in ${f.path}, cell ${invalidLoop.start.cellRef}`) } else { throw new Error(`Dynamic cells can not be defined in rows that contain ${invalidLoop.type} loops. Check Dynamic cells definition in ${f.path}, cell ${invalidLoop.start.cellRef}`) } } // we want to put the row loop wrapper around the start row wrapper currentLoop.blockStartEl = processOpeningTag(sheetDoc, startingRowEl.previousSibling, loopHelperCall.replace(startLoopRegexp, replaceLoopMatch)) // we want to put the row loop wrapper around the end row wrapper currentLoop.blockEndEl = processClosingTag(sheetDoc, endingRowEl.nextSibling, '{{/_D}}') const startCellEl = cellsElsByRefMap.get(currentLoop.start.cellRef)[0] // we want to put the column loop wrapper around the cell, we also // set the hierarchyId of this loop as a child loop let callForColumnLoop = loopHelperCall.replace(startLoopRegexp, replaceLoopMatch) callForColumnLoop = updateHandlebarsParameter(callForColumnLoop, 'cells=', ['cellsT', "'columns'"]) callForColumnLoop = updateHandlebarsParameter(callForColumnLoop, 'hierarchyId=', ['hierarchyId', `'${currentLoop.hierarchyId}#0'`]) processOpeningTag(sheetDoc, startCellEl, callForColumnLoop) processClosingTag(sheetDoc, startCellEl, '{{/_D}}') } else if (currentLoop.type === 'vertical') { const invalidLoop = loopsDetected.find((loop) => { if (loop.hierarchyId === currentLoop.hierarchyId) { return false } if (loop.type === 'vertical' || loop.type === 'dynamic') { // we are fine detecting just one side return ( loop.start.originalColumnNumber === parsedLoopStart.columnNumber && loop.start.originalRowNumber >= parsedLoopStart.rowNumber && loop.start.originalRowNumber <= parsedLoopEnd.rowNumber ) } else if (loop.type === 'row' || loop.type === 'block') { // we are fine detecting just one side in the case of block loops return ( loop.start.originalRowNumber >= parsedLoopStart.rowNumber && loop.start.originalRowNumber <= parsedLoopEnd.rowNumber ) } return false }) if (invalidLoop != null) { if (invalidLoop.type === 'vertical') { throw new Error(`Vertical loops can not have child vertical loops. Check child vertical loop definition in ${f.path}, cell ${invalidLoop.start.cellRef}`) } else { throw new Error(`Vertical loops can not be defined in rows that contain ${invalidLoop.type} loops. Check vertical loop definition in ${f.path}, cell ${invalidLoop.start.cellRef}`) } } const affectedRowNumbers = previousRows.filter(([pRowNumber]) => { return ( pRowNumber >= parsedLoopStart.rowNumber && pRowNumber <= parsedLoopEnd.rowNumber ) }).map(([pRowNumber]) => pRowNumber) affectedRowNumbers.push(parsedLoopEnd.rowNumber) for (const currentRowNumber of affectedRowNumbers) { const cellElsInRow = cellsByRowMap.get(currentRowNumber).filter((cellRef) => { const [, cellMeta] = cellsElsByRefMap.get(cellRef) return ( cellMeta.columnNumber >= parsedLoopStart.columnNumber && cellMeta.columnNumber <= parsedLoopEnd.columnNumber ) }).sort((a, b) => { const [, aMeta] = cellsElsByRefMap.get(a) const [, bMeta] = cellsElsByRefMap.get(b) return aMeta.columnNumber - bMeta.columnNumber }).map((cellRef) => cellsElsByRefMap.get(cellRef)[0]) const startEl = cellElsInRow[0] processOpeningTag(sheetDoc, startEl, loopHelperCall.replace(startLoopRegexp, replaceLoopMatch)) processClosingTag(sheetDoc, startEl, '{{/_D}}') } } else { if (currentLoop.type === 'row') { const invalidLoop = loopsDetected.find((loop) => { if (loop.hierarchyId === currentLoop.hierarchyId) { return false } if (loop.type === 'dynamic') { // we are fine detecting just one side return ( loop.start.originalColumnNumber === parsedLoopStart.columnNumber && loop.start.originalRowNumber >= parsedLoopStart.rowNumber && loop.start.originalRowNumber <= parsedLoopEnd.rowNumber ) } return false }) if (invalidLoop != null) { if (invalidLoop.type === 'dynamic') { throw new Error(`Row loops can not have child dynamic cells. Check child dynamic cell definition in ${f.path}, cell ${invalidLoop.start.cellRef}`) } } } // we want to put the loop wrapper around the start row wrapper currentLoop.blockStartEl = processOpeningTag(sheetDoc, startingRowEl.previousSibling, loopHelperCall.replace(startLoopRegexp, replaceLoopMatch)) // we want to put the loop wrapper around the end row wrapper currentLoop.blockEndEl = processClosingTag(sheetDoc, endingRowEl.nextSibling, '{{/_D}}') } } previousRows.push([originalRowNumber, rowEl]) } for (const cellRef of staticCellElsToHandle) { const [cellEl, cellMeta] = cellsElsByRefMap.get(cellRef) const cellInfo = getCellInfo(cellEl, sharedStringsEls, sheetFilepath) const fontSize = getFontSizeFromStyle(cellEl.getAttribute('s'), styleInfo) const currentMaxSize = colMaxSizeMap.get(cellMeta.letter) const currentSize = getPixelWidthOfValue(cellInfo.value, fontSize) if (currentMaxSize == null || currentSize > currentMaxSize) { colMaxSizeMap.set(cellMeta.letter, currentSize) } } const inverseLoopsDetected = [...loopsDetected].reverse() for (const { cellRef, loopCallsInCell, textDetails } of contentDetectCellElsToHandle) { // only process valid loop calls in cell, this means exclude inline loops const normalizedLoopCallsInCell = loopCallsInCell.filter((loopItemForCell) => { const validLoop = loopsDetected.find((loopItem) => loopItem.hierarchyId === loopItemForCell.hierarchyId) return validLoop }) const [cellEl, cellMeta] = cellsElsByRefMap.get(cellRef) let newTextValue = textDetails.getText() const textDetailFragments = [...textDetails] if (normalizedLoopCallsInCell.length > 0) { const textMatches = [] for (const loopCallInCell of normalizedLoopCallsInCell) { if (loopCallInCell.start.cellRef === cellRef) { textMatches.push({ startIdx: loopCallInCell.start.helperCallStartIdx, endIdx: loopCallInCell.start.helperCallStartIdx + (loopCallInCell.start.helperCall.length - 1) }) } if (loopCallInCell.end?.cellRef === cellRef) { textMatches.push({ startIdx: loopCallInCell.end.helperCallStartIdx, endIdx: loopCallInCell.end.helperCallStartIdx + (loopCallInCell.end.helperCall.length - 1) }) } } // sort the matches by start index ASC textMatches.sort((a, b) => a.startIdx - b.startIdx) const updatedTextFragments = removeMatchesInTextFragments(textDetailFragments, textMatches) newTextValue = '' for (const [fragmentIdx, updatedTextFragment] of updatedTextFragments.entries()) { textDetailFragments[fragmentIdx].text = updatedTextFragment.newText textDetailFragments[fragmentIdx].tEl.textContent = updatedTextFragment.newText newTextValue += updatedTextFragment.newText } } const templateEl = sheetDoc.createElement('is') const cellTemplateMeta = { templateEl } const lastLoopItem = getParentLoop(inverseLoopsDetected, parseCellRef(cellRef)) if (lastLoopItem?.isInside) { const asPart = lastLoopItem.loopDetected.start.helperCall.match(startLoopRegexp)[2] if (asPart != null) { cellTemplateMeta.asPart = asPart } } cellEl.setAttribute('r', cellMeta.letter) cellEl.setAttribute('__CT_t__', '_T') cellEl.setAttribute('__CT_cCU__', cellMeta.calcChainUpdate ? '1' : '0') // only try to detect content if the content for cell is expressed in single text cell if (textDetailFragments.length === 1) { const handlebarsRegexp = /{{{?(#[\w-]+\s+)?([\w-]+[^\n\r}]*)}?}}/g const matches = Array.from(newTextValue.matchAll(handlebarsRegexp)) const isSingleMatch = matches.length === 1 && matches[0][0] === newTextValue && matches[0][1] == null let newText if (isSingleMatch) { const match = matches[0] const expressionValue = match[2] const value = expressionValue.includes(' ') ? `(${expressionValue})` : expressionValue const props = {} if (!value.startsWith('(')) { // this is used in case we need to resolve from helper name props.n = value } newText = getDataHelperCall('cValue', props, { isBlock: false, valuePart: value }) } else { newText = `${getDataHelperCall('cValue')}${newTextValue}{{/_D}}` } textDetailFragments[0].text = newText textDetailFragments[0].tEl.textContent = newText } // clear the cell element content cellEl.textContent = '' const contentElements = textDetails.getContentElements() for (const contentEl of contentElements) { templateEl.appendChild(contentEl) } cellTemplatesMap.set(cellRef, cellTemplateMeta) } let outLoopItemIndex = 0 const sortedOutOfLoopElsToHandle = getOutOfLoopElsSortedByHierarchy(outOfLoopElsToHandle) for (const { loopDetected, startingRowEl, endingRowEl, types } of sortedOutOfLoopElsToHandle) { const loopLevel = loopDetected.hierarchyId.split('#').length const isOuterLevel = loopLevel === 1 for (const type of types) { const outOfLoopEl = sheetDoc.createElement('outOfLoop') const rowHandlebarsWrapperText = type === 'left' ? startingRowEl.previousSibling.textContent : endingRowEl.previousSibling.textContent const toCloneEls = [] const currentRowNumber = type === 'left' ? loopDetected.start.originalRowNumber : loopDetected.end.originalRowNumber const currentEl = type === 'left' ? loopDetected.start.el : loopDetected.end.el const remainingCellRefsInRow = cellsByRowMap.get(currentRowNumber).filter((cellRef) => { const parsedCellRef = parseCellRef(cellRef) if (type === 'left') { return parsedCellRef.columnNumber < loopDetected.start.originalColumnNumber } return parsedCellRef.columnNumber > loopDetected.end.originalColumnNumber }) const els = getCellElsAndWrappersFrom(currentEl, type === 'left' ? 'previous' : 'next') if (type === 'left') { els.reverse() } const items = [] let idx = 0 for (const cEl of els) { const item = [cEl] if (cEl.nodeName === 'c') { item.push(remainingCellRefsInRow[idx]) idx++ } items.push(item) } toCloneEls.push(...items) for (const [toCloneEl, cellRef] of toCloneEls) { const newEl = toCloneEl.cloneNode(true) outOfLoopEl.appendChild(newEl) if (cellRef != null) { // updating map of cellEl references const stored = cellsElsByRefMap.get(cellRef) stored[0] = newEl } toCloneEl.parentNode.removeChild(toCloneEl) } processOpeningTag(sheetDoc, outOfLoopEl.firstChild, rowHandlebarsWrapperText) processClosingTag(sheetDoc, outOfLoopEl.lastChild, '{{/_R}}') processOpeningTag(sheetDoc, outOfLoopEl.firstChild, `{{#_D t='outOfLoop' item='${outLoopItemIndex}' }}`) processClosingTag(sheetDoc, outOfLoopEl.lastChild, '{{/_D}}') const loopEdgeEl = loopDetected.blockStartEl const outOfLoopItemContentEls = nodeListToArray(outOfLoopEl.childNodes) for (const contentEl of outOfLoopItemContentEls) { loopEdgeEl.parentNode.insertBefore( contentEl, loopEdgeEl ) } const outOfLoopPlaceholderEl = sheetDoc.createElement('outOfLoopPlaceholder') const contentEl = sheetDoc.createElement('xlsxRemove') contentEl.textContent = `{{_D t='outOfLoopPlaceholder' item='${outLoopItemIndex}' }}` outOfLoopPlaceholderEl.appendChild(contentEl) // we only want to conditionally render the outOfLoopPlaceholder items if the loop // is at the outer level if (isOuterLevel) { // we include a if condition to preserve the cells that are before/after the each processOpeningTag(sheetDoc, outOfLoopPlaceholderEl.firstChild, `{{#if ${loopDetected.type === 'block' && type === 'right' ? '@last' : '@first'}}}`) processClosingTag(sheetDoc, outOfLoopPlaceholderEl.lastChild, '{{/if}}') } const outOfLoopPlaceholderContentEls = nodeListToArray(outOfLoopPlaceholderEl.childNodes) const cellAndWrappers = getCellElAndWrappers(type === 'left' ? loopDetected.start.el : loopDetected.end.el) if (type === 'right') { outOfLoopPlaceholderContentEls.reverse() } for (const contentEl of outOfLoopPlaceholderContentEls) { if (type === 'left') { loopDetected.start.el.parentNode.insertBefore(contentEl, cellAndWrappers[0]) } else { loopDetected.end.el.parentNode.insertBefore(contentEl, cellAndWrappers[cellAndWrappers.length - 1].nextSibling) } } outLoopItemIndex++ } } for (const [idx, { ref, rowEl }] of mergeCellElsToHandle.entries()) { const isLast = idx === mergeCellElsToHandle.length - 1 const newMergeCellCallEl = sheetDoc.createElement('xlsxRemove') const mergeStartCellRef = ref.split(':')[0] const parsedMergeStart = parseCellRef(mergeStartCellRef) const mergeStartMatch = cellsElsByRefMap.get(mergeStartCellRef) if (mergeStartMatch == null) { // this happens when there is merge cell that has no definition in row (row with no cells), // we put a cell call just to have the correct handlebars data.l updated before the merge helper call happens newMergeCellCallEl.textContent = `{{_C '${parsedMergeStart.letter}'}}{{_D t='m' o='${ref}'}}` rowEl.appendChild(newMergeCellCallEl) } else { const [mergeStartCellEl] = mergeStartMatch newMergeCellCallEl.textContent = `{{_D t='m' o='${ref}'}}` // since the updated cell letter does not have a parent-child relationship like // the updated row number has, then we need to insert the merge cell call just next to // the merge cell start to ensure that when it tries to read the updated cell letter // it is still the right value, specially for when there is vertical loop involved // in current cell or past cells mergeStartCellEl.parentNode.insertBefore(newMergeCellCallEl, mergeStartCellEl.nextSibling) } if (!isLast) { continue } mergeCellsEl.setAttribute('count', "{{_D t='mergeCellsCount'}}") processOpeningTag(sheetDoc, mergeCellsEl, "{{#_D t='mergeCells'}}") processClosingTag(sheetDoc, mergeCellsEl, '{{/_D}}') for (const mergeCellEl of mergeCellEls) { const originalCellRefRange = mergeCellEl.getAttribute('ref') mergeCellEl.setAttribute('ref', '{{newRef}}') processOpeningTag(sheetDoc, mergeCellEl, `{{#_D t='mI' o='${originalCellRefRange}'}}`) processClosingTag(sheetDoc, mergeCellEl, '{{/_D}}') } processOpeningTag(sheetDoc, mergeCellsEl.firstChild, "{{#_D t='mergeCellsItems'}}") processClosingTag(sheetDoc, mergeCellsEl.lastChild, '{{/_D}}') } const formulaNotExistingCellRefs = new Set() for (const { formula, formulaEl, sharedFormula, cellRefsInFormula } of formulaCellElsToHandle) { if (sharedFormula?.type === 'reference') { continue } formulaEl.textContent = `{{_D t='f' o='${jsSingleQuoteEscape(formula)}'` if (sharedFormula?.type === 'source') { formulaEl.setAttribute('ref', `{{_D t='fs' o='${jsSingleQuoteEscape(sharedFormula.sourceRef)}'}}`) } formulaEl.textContent += '}}' for (const cellRefInfo of cellRefsInFormula) { // we don't process formulas with references to other sheets if (cellRefInfo.parsed.sheetName != null) { continue } // we need to normalize to ignore the possible locked symbols ($) const normalizedCellRef = cellRefInfo.localRef const targetCellEl = cellsElsByRefMap.get(normalizedCellRef)?.[0] if (targetCellEl != null) { continue } const parsedNormalizedCellRef = parseCellRef(normalizedCellRef) const loopDetectionResult = getParentLoop(inverseLoopsDetected, parsedNormalizedCellRef) let value = normalizedCellRef if (loopDetectionResult != null) { value += `|${loopDetectionResult.loopDetected.hierarchyId}` } // we get here when a formula references a cell which // does not have any content (so it does not have a corresponding cell element). // using a Set also guarantees that we only process the cel ref once, // it does not matter that multiple formulas references the same cell // we just want one entry for it. formulaNotExistingCellRefs.add(value) } } if (formulaNotExistingCellRefs.size > 0) { sheetDataCallProps.nonExistingCellRefs = [...formulaNotExistingCellRefs].join(',') } // handle possible lazy formulas (formulas that reference other cells that are not yet processed) // example case: // E2 cell = formula (10+E7) // there is loop on row 5 // E7 cell = 30 // the final output of E2 should be 10+<newCellRefAfterLoop> // NOTE: if we find this approach is slow because the regexp requirement we can // switch to a different approach based on Defined names, which we can set on different place // either Workbook or Sheet level, use dynamic calculated names when processing Sheets, and then // on single last step declared the Defined names. // with this we can avoid the extra regexp step which // is for sure slow if you create many of the formulas that will require the final references // to be evaluated at final step const newLazyFormulasCallEl = sheetDoc.createElement('xlsxRemove') newLazyFormulasCallEl.textContent = "{{_D t='lazyFormulas'}}" sheetDataEl.appendChild(newLazyFormulasCallEl) if (isAutofitConfigured) { const colsProps = {} const baseCols = [] for (const [cellLetter, maxSize] of colMaxSizeMap) { baseCols.push(`${cellLetter}:${maxSize}`) } if (baseCols.length > 0) { sheetDataCallProps.autofitBCols = baseCols.join(',') } processOpeningTag(sheetDoc, colsEl, getDataHelperCall('autofit', colsProps)) processClosingTag(sheetDoc, colsEl, '{{/_D}}') } if (cellTemplatesMap.size > 0) { // we want the cells in reverse order because we want to insert in ASC order in the xml const cellTemplates = [...cellTemplatesMap.entries()].reverse() for (const [cellRef, { templateEl, asPart }] of cellTemplates) { sheetDoc.documentElement.insertBefore(templateEl, sheetDoc.documentElement.firstChild) // we need to define the template with the as part if it exists, this is important to preserve // blockParams resolution if the loop call has it processOpeningTag(sheetDoc, sheetDoc.documentElement.firstChild, getDataHelperCall('cTmpl', { cellRef }, { asPart })) processClosingTag(sheetDoc, templateEl, '{{/_D}}') } } sheetDataBlockStartEl.textContent = getDataHelperCall('sd', sheetDataCallProps) processOpeningTag(sheetDoc, sheetDoc.documentElement.firstChild, getDataHelperCall('ws', { sheetId: sheetInfo.id })) processClosingTag(sheetDoc, sheetDoc.documentElement.lastChild, '{{/_D}}') } // normalize the shared string values used across the sheets that can contain handlebars code for (const sharedStringEl of sharedStringsEls) { const tEls = nodeListToArray(sharedStringEl.getElementsByTagName('t')) for (const tEl of tEls) { if (tEl.textContent.includes('{{') && tEl.textContent.includes('}}')) { tEl.textContent = `{{{{_D t='raw'}}}}${tEl.textContent}{{{{/_D}}}}` } } } // place handlebars call that handle updating the calcChain if (calcChainDoc != null) { processOpeningTag(calcChainDoc, calcChainDoc.documentElement.firstChild, getDataHelperCall('calcChain')) processClosingTag(calcChainDoc, calcChainDoc.documentElement.lastChild, '{{/_D}}') } } function getDataHelperCall (type, props, { isBlock = true, valuePart = '', asPart = '' } = {}) { let callStr = `{{${isBlock ? '#' : ''}_D` const targetProps = props || {} const keys = Object.keys(targetProps) if (valuePart != null && valuePart !== '') { callStr += ` ${valuePart}` } callStr += ` t='${type}'` for (const key of keys) { const value = targetProps[key] if (value == null) { continue } if (typeof value === 'number' || typeof value === 'boolean') { callStr += ` ${key}=${value}` } else { callStr += ` ${key}='${value}'` } } if (asPart != null && asPart !== '') { callStr += ` ${asPart}` } callStr += '}}' return callStr } function getOutOfLoopElsSortedByHierarchy (outOfLoopElsToHandle) { const hierarchy = new Map() const hierarchyIdElMap = new Map() for (let idx = 0; idx < outOfLoopElsToHandle.length; idx++) { const outOfLoopElToHandle = outOfLoopElsToHandle[idx] const hierarchyId = outOfLoopElToHandle.loopDetected.hierarchyId hierarchyIdElMap.set(hierarchyId, outOfLoopElToHandle) const parts = hierarchyId.split('#') let currentHierarchy = hierarchy for (let partIdx = 0; partIdx < parts.length; partIdx++) { const occurrenceIdx = parseInt(parts[partIdx], 10) const isLast = partIdx === parts.length - 1 if (!currentHierarchy.has(occurrenceIdx)) { currentHierarchy.set(occurrenceIdx, { children: new Map() }) } const currentItem = currentHierarchy.get(occurrenceIdx) if (isLast) { currentItem.match = hierarchyId } currentHierarchy = currentItem.children } } const toArraySortedByOccurrence = (targetMap) => { const sortedKeysByOccurrence = [...targetMap.keys()].sort((a, b) => a - b) return sortedKeysByOccurrence.map((key) => targetMap.get(key)) } const pending = toArraySortedByOccurrence(hierarchy) const sortedHierarchyIds = [] while (pending.length > 0) { const currentPending = pending.shift() if (typeof currentPending === 'string') { sortedHierarchyIds.push(currentPending) continue } const item = currentPending if (item.children.size > 0) { pending.unshift(...toArraySortedByOccurrence(item.children)) } if (item.match != null) { pending.unshift(item.match) } } const result = sortedHierarchyIds.map((hierarchyId) => hierarchyIdElMap.get(hierarchyId)) return result } function getLatestNotClosedLoop (loopsDetected, all = false) { const found = [] for (let index = loopsDetected.length - 1; index >= 0; index--) { const currentLoop = loopsDetected[index] if (currentLoop.end != null) { continue } found.push(currentLoop) if (!all) { break } } return all ? found : found[0] } function getParentLoop (inverseLoopsDetected, parsedCellRef) { // we check here if there is a loop that start/end in the same row of cell // (this does not necessarily mean that cell is part of the loop) const loopDetected = inverseLoopsDetected.find((l) => { switch (l.type) { case 'row': case 'dynamic': return l.start.originalRowNumber === parsedCellRef.rowNumber case 'block': return ( parsedCellRef.rowNumber >= l.start.originalRowNumber && parsedCellRef.rowNumber <= l.end.originalRowNumber ) case 'vertical': return ( parsedCellRef.rowNumber >= l.start.originalRowNumber && parsedCellRef.rowNumber <= l.end.originalRowNumber && parsedCellRef.columnNumber === l.start.originalColumnNumber ) default: throw new Error(`Unknown loop type ${l.type}`) } }) if (loopDetected == null) { return } const parsedLoopStart = parseCellRef(loopDetected.start.cellRef) const parsedLoopEnd = parseCellRef(loopDetected.end.cellRef) let insideLoop = false // here we check if the cell is really part of the loop or not switch (loopDetected.type) { case 'row': case 'dynamic': { insideLoop = ( parsedCellRef.columnNumber >= parsedLoopStart.columnNumber && parsedCellRef.columnNumber <= parsedLoopEnd.columnNumber ) break } case 'block': { if (parsedLoopStart.rowNumber === parsedCellRef.rowNumber) { insideLoop = parsedCellRef.columnNumber >= parsedLoopStart.columnNumber } else if (parsedLoopEnd.rowNumber === parsedCellRef.rowNumber) { insideLoop = parsedCellRef.columnNumber <= parsedLoopEnd.columnNumber } else { insideLoop = true } break } case 'vertical': { // if it passed the checks, nothing else to do here insideLoop = true break } default: throw new Error(`Unknown loop type ${loopDetected.type}`) } return { loopDetected, parsedLoopStart, parsedLoopEnd, isInside: insideLoop } } function getCellElAndWrappers (referenceCellEl) { if (referenceCellEl?.nodeName !== 'c') { throw new Error('currentEl parameter must be a cell "c"') } const els = [referenceCellEl] const targets = ['previousSibling', 'nextSibling'] let currentEl for (const target of targets) { currentEl = referenceCellEl while (currentEl[target] != null) { const isWrapper = ( currentEl[target].nodeName === 'xlsxRemove' && currentEl[target].textContent.startsWith(target === 'previousSibling' ? '{{#_D' : '{{/_D') ) if (!isWrapper) { break } if (target === 'previousSibling') { els.unshift(currentEl[target]) } else { els.push(currentEl[target]) } currentEl = currentEl[target] } } return els } function getCellElsAndWrappersFrom (referenceCellEl, type = 'previous') { if (type !== 'previous' && type !== 'next') { throw new Error('type parameter must be previous or next') } const els = [] let ready = false const target = type === 'previous' ? 'previousSibling' : 'nextSibling' let currentEl = referenceCellEl while (currentEl[target] != null) { if (!ready) { ready = ( // if the node is not xlsxRemove then we are ready currentEl[target].nodeName !== 'xlsxRemove' || // if it is xlsxRemove but it is not end point then we are ready !currentEl[target].textContent.startsWith(type === 'previous' ? '{{#_D' : '{{/_D') ) } if (ready) { els.push(currentEl[target]) } currentEl = currentEl[target] } return els } function findAutofitConfigured (sheetFilepath, sheetDoc, sheetRelsDoc, files) { const result = [] const legacyDrawingEls = nodeListToArray(sheetDoc.getElementsByTagName('legacyDrawing')) const relationshipEls = sheetRelsDoc == null ? [] : nodeListToArray(sheetRelsDoc.getElementsByTagName('Relationship')) const commentRelEl = relationshi