UNPKG

@jsreport/jsreport-xlsx

Version:

jsreport recipe rendering excels directly from open xml

608 lines (481 loc) 20.5 kB
/* global structuredClone */ const { DOMParser } = require('@xmldom/xmldom') const { serializeXmlAsHandlebarsSafeOutput, getDataHelperCall, getDataHelperBlockEndCall, recreateNodeWithNewDoc } = require('../../../utils') const startLoopRegexp = /{{#each\s+([^{|}]{0,500})(?:\s*(as\s+\|\w+\|))?\s*}}/ module.exports = function generateDataTemplate ( dataRanges, templateItems, dynamicParts, parsedCells, autofitConfigured ) { const templateManager = getTemplateManager() for (const dataRange of dataRanges) { if (dataRange.type === 'static') { templateManager.addPart(`${getDataHelperCall( 'staticRange', { start: dataRange.start, end: dataRange.end }, { content: '{{> __static_row__ }}' } )}`) } else if (dataRange.type === 'dynamic') { for (let elementIdx = dataRange.start; elementIdx <= dataRange.end; elementIdx++) { const item = templateItems.data[elementIdx] const rowNumber = item.id const dynamicRow = dynamicParts.rows.get(rowNumber) const loopCallsForRow = { pre: [], post: [] } const cellTemplateParts = [] for (const cellRef of dynamicRow.cellRefs) { const parsedCell = parsedCells.get(cellRef) if (parsedCell.type === 'static') { cellTemplateParts.push({ letter: parsedCell.letter, static: true }) } else if (parsedCell.type === 'dynamic') { // check if the cell contain either the start or end of loops const loopsInCell = parsedCell.loops?.size > 0 ? Array.from(parsedCell.loops) : [] let newTextValue = parsedCell.textDetails.getText() const textDetailFragments = [...parsedCell.textDetails] if (loopsInCell.length > 0) { const textMatches = [] for (const loopInCell of loopsInCell) { if (loopInCell.start.cellRef === cellRef) { textMatches.push({ startIdx: loopInCell.start.helperCallStartIdx, endIdx: loopInCell.start.helperCallStartIdx + (loopInCell.start.helperCall.length - 1) }) if (loopInCell.type === 'block' || loopInCell.type === 'row' || loopInCell.type === 'dynamic') { loopCallsForRow.pre.push(loopInCell.start.helperCall.replace(startLoopRegexp, getLoopCallWithDataHelperReplacer(loopInCell))) } } if (loopInCell.end.cellRef === cellRef) { textMatches.push({ startIdx: loopInCell.end.helperCallStartIdx, endIdx: loopInCell.end.helperCallStartIdx + (loopInCell.end.helperCall.length - 1) }) if (loopInCell.type === 'block' || loopInCell.type === 'row' || loopInCell.type === 'dynamic') { loopCallsForRow.post.push(getDataHelperBlockEndCall()) } } } // 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 } } // 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', null, { content: newTextValue }) } textDetailFragments[0].text = newText textDetailFragments[0].tEl.textContent = newText } const contentElements = parsedCell.textDetails.getContentElements() const tmpDoc = new DOMParser().parseFromString('<is />') for (const contentEl of contentElements) { // we recreate the node to avoid getting unwanted xmlns attributes // when serializing tmpDoc.documentElement.appendChild( recreateNodeWithNewDoc(contentEl, tmpDoc) ) } cellTemplateParts.push({ letter: parsedCell.letter, content: serializeXmlAsHandlebarsSafeOutput(tmpDoc) }) } else { throw new Error(`Parsed Cell of type "${parsedCell.type}" is not supported`) } } templateManager.addRowPart(rowNumber, cellTemplateParts) } } else { throw new Error(`Data range of type "${dataRange.type}" is not supported`) } } const outOfLoopData = { items: [], idx: -1 } for (const loopItem of dynamicParts.loops) { const startRowPart = templateManager.getRowCellPart(loopItem.start.rowNumber) const endRowPart = templateManager.getRowCellPart(loopItem.end.rowNumber) const isRowBased = loopItem.type === 'row' || loopItem.type === 'block' || loopItem.type === 'dynamic' let outOfLoopPart if (isRowBased) { const startRowCellRefs = dynamicParts.rows.get(loopItem.start.rowNumber).cellRefs const endRowCellRefs = dynamicParts.rows.get(loopItem.end.rowNumber).cellRefs const startCellRefIdx = startRowCellRefs.indexOf(loopItem.start.cellRef) const endCellRefIdx = endRowCellRefs.indexOf(loopItem.end.cellRef) const previousCellRefs = startRowCellRefs.slice(0, startCellRefIdx) const nextCellRefs = endRowCellRefs.slice(endCellRefIdx + 1) const outOfLoopItem = {} for (const type of ['left', 'right']) { const cellRefs = type === 'left' ? previousCellRefs : nextCellRefs if (cellRefs.length === 0) { continue } outOfLoopData.idx++ outOfLoopItem[type] = { idx: outOfLoopData.idx, rowNumber: type === 'left' ? loopItem.start.rowNumber : loopItem.end.rowNumber, cellRefs } } if (Object.keys(outOfLoopItem).length > 0) { outOfLoopItem.part = { children: [] } outOfLoopPart = outOfLoopItem.part outOfLoopItem.loopItem = loopItem outOfLoopData.items.push(outOfLoopItem) } } if (loopItem.type === 'dynamic') { startRowPart.pre.push(loopItem.start.helperCall.replace(startLoopRegexp, getLoopCallWithDataHelperReplacer(loopItem))) endRowPart.post.unshift(getDataHelperBlockEndCall()) const startCellPart = templateManager.getRowCellPart(loopItem.start.rowNumber, loopItem.start.letter) // we want to put the column loop wrapper around the cell, we also // set the hierarchyId of this loop as a child loop let loopCallForColumn = loopItem.start.helperCall.replace(startLoopRegexp, getLoopCallWithDataHelperReplacer(loopItem)) loopCallForColumn = updateHandlebarsParameter(loopCallForColumn, 'cells=', ['cellsT', "'columns'"]) loopCallForColumn = updateHandlebarsParameter(loopCallForColumn, 'hierarchyId=', ['hierarchyId', `'${loopItem.hierarchyId}#0'`]) startCellPart.pre.push(loopCallForColumn) startCellPart.post.unshift(getDataHelperBlockEndCall()) } else if (loopItem.type === 'vertical') { for (const rowNumber of loopItem.rows) { const dynamicRow = dynamicParts.rows.get(rowNumber) const cellRefsForLoop = dynamicRow.cellRefs.filter((cellRef) => { const parsedCell = parsedCells.get(cellRef) return ( parsedCell.columnNumber >= loopItem.start.columnNumber && parsedCell.columnNumber <= loopItem.end.columnNumber ) }) const startCellRef = cellRefsForLoop[0] const parsedStartCell = parsedCells.get(startCellRef) const startCellPart = templateManager.getRowCellPart(rowNumber, parsedStartCell.letter) startCellPart.pre.push(loopItem.start.helperCall.replace(startLoopRegexp, getLoopCallWithDataHelperReplacer(loopItem))) startCellPart.post.unshift(getDataHelperBlockEndCall()) } } else { startRowPart.pre.push(loopItem.start.helperCall.replace(startLoopRegexp, getLoopCallWithDataHelperReplacer(loopItem))) endRowPart.post.unshift(getDataHelperBlockEndCall()) } if (outOfLoopPart) { // reserve the spot, we are going to fill it bellow // when processing out of loop items startRowPart.pre.push(outOfLoopPart) } } // we process out of loop items after loops to ensure a proper order // of wrapper calls for (const outOfLoopItem of outOfLoopData.items) { for (const type of ['left', 'right']) { if (outOfLoopItem[type] == null) { continue } const outOfLoopSide = outOfLoopItem[type] const loopItem = outOfLoopItem.loopItem const loopLevel = loopItem.hierarchyId.split('#').length const isOuterLevel = loopLevel === 1 // we need to move the cells that are out of the loop above the loop helper call, // this ensures that these cells does not have access to the context in the loop outOfLoopItem.part.children.push(`{{#*inline "__outOfLoop${outOfLoopSide.idx}__"}}`) for (let idx = 0; idx < outOfLoopSide.cellRefs.length; idx++) { const cellRef = outOfLoopSide.cellRefs[idx] const isFirst = idx === 0 const currentParsedCellRef = parsedCells.get(cellRef) const cellPart = templateManager.getRowCellPart(outOfLoopSide.rowNumber, currentParsedCellRef.letter) const clonedCellPart = structuredClone(cellPart) // clean the cells that are out of the loop and just put the outOfLoopOutput // call on the start cell cellPart.pre = [] cellPart.children = [] cellPart.post = [] if (isFirst) { if (isOuterLevel) { // we include an if condition to preserve the cells that are before/after the loop // but only do it for the outer loops cellPart.pre.push(`{{#if ${loopItem.type === 'block' && type === 'right' ? '@last' : '@first'}}}`) } cellPart.children.push(`{{> __outOfLoop${outOfLoopSide.idx}__ ../this }}`) if (isOuterLevel) { cellPart.post.unshift('{{/if}}') } } outOfLoopItem.part.children.push(clonedCellPart) } outOfLoopItem.part.children.push('{{/inline}}') } } const inlinePartials = [`{{#*inline "__static_row__"}}${ getDataHelperCall('r', null, { valuePart: 'rowNumber', content: `{{#each cells}}${getDataHelperCall('c', null, { isBlock: false, valuePart: './this' })}{{/each}}` }) }{{/inline}}`] const dataTemplateParts = [] if (autofitConfigured.length > 0) { // we are interested in always move the call of the first cell to the // body of the sheet, this is needed in order to correctly detect if all // cols should have autofit const firstCellConf = autofitConfigured.find((conf) => ( conf.row === 0 && conf.column === 0 )) const tEls = Array.from(firstCellConf.commentEl.getElementsByTagName('t')) const tCallEl = tEls.find((tEl) => tEl.textContent.startsWith('{{xlsxColAutofit')) const autoFitCallRegExp = /{{xlsxColAutofit( [^}]*)?}}/ if (tCallEl != null) { const autoFitCallMatch = autoFitCallRegExp.exec(tCallEl.textContent) // we are interested in always move the call of the first cell to the // body of the sheet, this is needed in order to correctly detect if all // cols should have autofit dataTemplateParts.push(tCallEl.textContent.slice( autoFitCallMatch.index, autoFitCallMatch.index + autoFitCallMatch[0].length )) } } dataTemplateParts.push(inlinePartials.join('\n')) for (const part of templateManager.getParts()) { const pending = [part] while (pending.length > 0) { const pendingPart = pending.shift() if (typeof pendingPart === 'string') { dataTemplateParts.push(pendingPart) } else { const preParts = pendingPart.pre ?? [] const postParts = pendingPart.post ?? [] const childrenParts = pendingPart.children ?? [] pending.unshift(...[ ...preParts, ...childrenParts, ...postParts ]) } } } // insert a call to resolve any possible last lazy formulas dataTemplateParts.push( getDataHelperCall('lazyFormulas', null, { isBlock: false }) ) return dataTemplateParts.join('\n') } function getTemplateManager () { const parts = [] const rowToIdxPartMap = new Map() return { addPart (content) { parts.push(content) }, addRowPart (rowNumber, cellParts) { const mapEntry = {} const contentParts = [ getDataHelperCall('r', undefined, { // we could use the element index as the parameter, however // we choose to use the row number to allow easier debugging valuePart: rowNumber }) ] for (const cellPart of cellParts) { if (mapEntry.cells == null) { mapEntry.cells = new Map() } mapEntry.cells.set(cellPart.letter, contentParts.length) const cellHelperCall = { props: {}, options: { valuePart: `'${cellPart.letter}'` } } if (cellPart.static === true) { cellHelperCall.options.isBlock = false } else { if (cellPart.content == null) { throw new Error('Cell part should have a .content property') } cellHelperCall.options.content = cellPart.content } const cellContentParts = [ getDataHelperCall('c', cellHelperCall.props, cellHelperCall.options) ] contentParts.push({ pre: [], post: [], children: cellContentParts }) } contentParts.push(getDataHelperBlockEndCall()) mapEntry.idx = parts.length rowToIdxPartMap.set(rowNumber, mapEntry) parts.push({ pre: [], post: [], children: contentParts }) }, getRowCellPart (rowNumber, columnLetter) { const mapEntry = rowToIdxPartMap.get(rowNumber) let targetPart if (columnLetter == null) { targetPart = parts[mapEntry?.idx] if (targetPart == null) { throw new Error(`No part found for row number ${rowNumber}`) } } else { const cellPartIdx = mapEntry?.cells?.get(columnLetter) targetPart = parts[mapEntry?.idx]?.children?.[cellPartIdx] if (targetPart == null) { throw new Error(`No part found for row number ${rowNumber} and column letter ${columnLetter}`) } } return targetPart }, getParts () { return [...parts] } } } function getLoopCallWithDataHelperReplacer (currentLoop) { return (match, dataExpressionPart, asPart) => { const targetDataExpressionPart = dataExpressionPart.trimEnd() const targetAsPart = asPart != null && asPart !== '' ? asPart.trim() : '' const props = { hierarchyId: currentLoop.hierarchyId, start: currentLoop.start.rowNumber } if (currentLoop.type === 'block') { props.end = currentLoop.end.rowNumber } props.columnStart = currentLoop.start.columnNumber props.columnEnd = currentLoop.end.columnNumber return getDataHelperCall('loop', props, { valuePart: targetDataExpressionPart, asPart: targetAsPart }) } } function updateHandlebarsParameter (helperCall, parameterStr, keyValue) { const startParameterIdx = helperCall.indexOf(parameterStr) if (startParameterIdx === -1) { return helperCall } const remainingHelperCall = helperCall.slice(startParameterIdx + parameterStr.length) let startValueMatch = remainingHelperCall.match(/["'(@\w]/) if (startValueMatch != null) { const startIdx = startValueMatch.index startValueMatch = startValueMatch[0] let endDelimiter switch (startValueMatch) { case '(': endDelimiter = ')' break case '"': endDelimiter = '"' break case "'": endDelimiter = "'" break default: endDelimiter = ' ' break } let endIdx = -1 if (endDelimiter === ' ') { // we consider the case where the parameter is the last value of helper call const currentRemaining = remainingHelperCall.slice(startIdx) const endValueMatch = currentRemaining.match(/[ }]/) if (endValueMatch != null) { endIdx = startIdx + endValueMatch.index } } else { endIdx = remainingHelperCall.indexOf(endDelimiter, startIdx + 1) if (endIdx !== -1) { // for the rest of delimiters other than space we want to include the delimiter itself endIdx += 1 } } if (endIdx !== -1) { const [key, value] = keyValue return `${helperCall.slice(0, startParameterIdx)}${key}=${value}${helperCall.slice(startParameterIdx + parameterStr.length + endIdx)}` } return helperCall } return helperCall } function removeMatchesInTextFragments (textFragments, targetMatches) { const matchedFragments = [] let fragmentStartIdx = 0 const targetFragments = textFragments.map((match) => ({ ...match })) while (targetFragments.length > 0) { const fragment = targetFragments.shift() if (fragment.text === '') { matchedFragments.push({ ...fragment, newText: '' }) continue } const originalText = fragment.text let currentStartIdx = fragmentStartIdx const currentEndIdx = fragmentStartIdx + originalText.length - 1 let newText = originalText const pendingParts = [] for (const { startIdx, endIdx } of targetMatches) { if (currentStartIdx > endIdx || currentStartIdx > currentEndIdx) { continue } const startHasMatch = startIdx >= currentStartIdx && startIdx <= currentEndIdx const middleMatch = endIdx >= currentStartIdx && endIdx > currentEndIdx const endHasMatch = endIdx >= currentStartIdx && endIdx <= currentEndIdx if (startHasMatch || middleMatch || endHasMatch) { const leftPartStart = 0 let leftPartEnd = startIdx - currentStartIdx if (leftPartEnd < 0) { leftPartEnd = 0 } let rightPartStart = endIdx - currentStartIdx + 1 if (rightPartStart < 0) { rightPartStart = 0 } let rightPartEnd = rightPartStart + (currentEndIdx - endIdx) if (rightPartEnd < 0) { rightPartEnd = 0 } const middleRemove = leftPartEnd - leftPartStart !== 0 && rightPartEnd - rightPartStart !== 0 if (middleRemove) { // save the remaining part for later pendingParts.push(newText.slice(leftPartStart, leftPartEnd)) newText = newText.slice(rightPartStart, rightPartEnd) } else { newText = `${newText.slice(leftPartStart, leftPartEnd)}${newText.slice(rightPartStart, rightPartEnd)}` } currentStartIdx = currentStartIdx + rightPartStart } } newText = pendingParts.join('') + newText matchedFragments.push({ ...fragment, newText }) fragmentStartIdx = currentEndIdx + 1 } return matchedFragments }