@jsreport/jsreport-xlsx
Version:
jsreport recipe rendering excels directly from open xml
1,374 lines (1,101 loc) • 53.4 kB
JavaScript
const path = require('path')
const { nodeListToArray, isWorksheetFile, isWorksheetRelsFile, getSheetInfo, getCellInfo, getDataHelperCall, getStyleFile, getStyleInfo } = require('../../../utils')
const { parseCellRef, getColumnFor, getPixelWidthOfValue, getFontSizeFromStyle, evaluateCellRefsFromExpression } = require('../../../cellUtils')
const generateDataTemplate = require('./generateDataTemplate')
module.exports = ({ files, sharedData, addEndCallback }) => {
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 calcChainFilePath = 'xl/calcChain.xml'
const calcChainDoc = files.find((f) => f.path === calcChainFilePath)?.doc
const styleInfo = getStyleInfo(getStyleFile(files)?.doc)
const workbookCalcPrEl = workbookDoc.getElementsByTagName('calcPr')[0]
let workbookSheetsEls = []
let workbookRelsEls = []
let sharedStringsEls = []
if (workbookDoc) {
workbookSheetsEls = nodeListToArray(workbookDoc.getElementsByTagName('sheet'))
}
if (workbookRelsDoc != null) {
workbookRelsEls = nodeListToArray(workbookRelsDoc.getElementsByTagName('Relationship'))
}
if (sharedStringsDoc != null) {
sharedStringsEls = nodeListToArray(sharedStringsDoc.getElementsByTagName('si'))
}
const calcChainRefElementIdxMap = new Map()
if (calcChainDoc != null) {
sharedData.calcChainFilePath = calcChainFilePath
const templateItems = createTemplateItems()
for (const calcChainChildEl of Array.from(calcChainDoc.documentElement.childNodes)) {
storeElement(
templateItems.data, calcChainChildEl, {
defaults: calcChainChildEl.nodeName === 'c' ? null : { type: calcChainChildEl.nodeName },
idProp: calcChainChildEl.nodeName === 'c' ? 'r' : null,
// we store the "r" attribute but just as empty, we just care to keep its
// position in attribute list
emptyAttrs: calcChainChildEl.nodeName === 'c' ? ['r'] : []
},
calcChainChildEl.nodeName, templateItems.elementTypeAttributesMap
)
if (calcChainChildEl.nodeName === 'c') {
calcChainRefElementIdxMap.set(
`${calcChainChildEl.getAttribute('i')}-${calcChainChildEl.getAttribute('r')}`,
templateItems.data.length - 1
)
}
}
// simplify the elements attribute data structure
for (const [elementType, elementAttributes] of templateItems.elementTypeAttributesMap) {
templateItems.elementTypeAttributesMap.set(elementType, elementAttributes.data)
}
sharedData.fileDataMap.set(calcChainFilePath, {
templateItems,
dataItems: []
})
}
const openTagRegexp = /{/g
const closeTagRegexp = /}/g
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 sheetDataChildEls = Array.from(sheetDataEl.childNodes)
const lastRowIdx = sheetDataChildEls.filter((n) => n.nodeName === 'row').length - 1
// looking for autofit comments in the sheet
const autoFitConfigured = findAutoFitConfigured(sheetFilepath, sheetDoc, sheetRelsDoc, files)
const isAutofitConfigured = autoFitConfigured.length > 0
const autoFitData = {
enabledFor: [],
cols: new Map()
}
// we expect the column to be 1 based
autoFitData.enabledFor = autoFitConfigured.map((r) => getColumnFor(r.column + 1)[0])
const mergeCellsEl = sheetDoc.getElementsByTagName('mergeCells')[0]
// stores the merge cell ranges by the row number
const mergeCellRangesByStartRowNumberMap = new Map()
for (const [mergeCellIdx, mergeCellEl] of Array.from(mergeCellsEl?.getElementsByTagName('mergeCell') ?? []).entries()) {
const ref = mergeCellEl.getAttribute('ref')
const startCellRef = ref.split(':')[0]
const parsedStartCellRef = parseCellRef(startCellRef)
let refsByStartLetterMap = mergeCellRangesByStartRowNumberMap.get(parsedStartCellRef.rowNumber)
if (refsByStartLetterMap == null) {
refsByStartLetterMap = new Map()
mergeCellRangesByStartRowNumberMap.set(parsedStartCellRef.rowNumber, refsByStartLetterMap)
}
refsByStartLetterMap.set(parsedStartCellRef.letter, { idx: mergeCellIdx, ref })
}
const dimensionEl = sheetDoc.getElementsByTagName('dimension')[0]
let parsedDimension
// parse the initial dimension ref if exists
if (dimensionEl?.hasAttribute('ref') && dimensionEl.getAttribute('ref') !== '') {
const dimensionRef = dimensionEl.getAttribute('ref')
const refsParts = dimensionRef.split(':')
parsedDimension = { start: null, end: null }
if (refsParts.length === 1) {
const parsedPart = parseCellRef(refsParts[0])
parsedDimension.start = {
rowNumber: parsedPart.rowNumber,
columnNumber: parsedPart.columnNumber
}
parsedDimension.end = {
rowNumber: parsedPart.rowNumber,
columnNumber: parsedPart.columnNumber
}
} else {
const parsedPart = parseCellRef(refsParts[0])
const parsedPart2 = parseCellRef(refsParts[1])
parsedDimension.start = {
rowNumber: parsedPart.rowNumber,
columnNumber: parsedPart.columnNumber
}
parsedDimension.end = {
rowNumber: parsedPart2.rowNumber,
columnNumber: parsedPart2.columnNumber
}
}
}
const tableParts = {
filePaths: [],
refsMeta: new Map(),
refByCellRefs: new Map(),
refSourceEls: []
}
// check if there are tables, if there are we need to update its refs at runtime
const tablePartsEl = sheetDoc.getElementsByTagName('tableParts')[0]
if (tablePartsEl != null) {
const tablePartEls = Array.from(tablePartsEl.childNodes).filter((n) => n.nodeName === 'tablePart')
const relationshipEls = Array.from(sheetRelsDoc.getElementsByTagName('Relationship'))
for (const tablePartEl of tablePartEls) {
const tableRelId = tablePartEl.getAttribute('r:id')
const tableRelEl = relationshipEls.find((rel) => (
rel.getAttribute('Id') === tableRelId &&
rel.getAttribute('Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table'
))
if (tableRelEl == null) {
throw new Error(`Could not find relationship element for table reference in sheet at ${sheetFilepath}`)
}
const tableFilePath = path.posix.join(path.posix.dirname(sheetFilepath), tableRelEl.getAttribute('Target'))
const tableDoc = files.find((file) => file.path === tableFilePath).doc
if (tableDoc == null) {
throw new Error(`Could not find table document referenced in sheet at ${sheetFilepath}`)
}
const targetRefSourceEls = [tableDoc.documentElement]
const autoFilterEl = Array.from(tableDoc.documentElement.childNodes).find((n) => n.nodeName === 'autoFilter')
if (autoFilterEl != null) {
targetRefSourceEls.push(autoFilterEl)
}
tableParts.filePaths.push(tableFilePath)
for (const targetRefSourceEl of targetRefSourceEls) {
const rangeRef = targetRefSourceEl.getAttribute('ref')
const rangeParts = rangeRef.split(':')
if (rangeParts.length !== 2) {
throw new Error(`Unexpected range ref format "${rangeRef}" in table reference in sheet at ${sheetFilepath}`)
}
let refMeta = tableParts.refsMeta.get(rangeRef)
if (refMeta == null) {
refMeta = {}
tableParts.refsMeta.set(rangeRef, refMeta)
refMeta.tableFilePathIdx = tableParts.filePaths.length - 1
refMeta.dataVariableName = `newTableRef${tableParts.refsMeta.size - 1}`
tableParts.refByCellRefs.set(rangeParts[0], rangeRef)
tableParts.refByCellRefs.set(rangeParts[1], rangeRef)
}
tableParts.refSourceEls.push(targetRefSourceEl)
}
sharedData.fileDataMap.set(tableFilePath, {
// initialize container, it is going to be fill at handlebars runtime
dataVariables: {}
})
}
}
// store information about the cells evaluated at runtime in handlebars,
// in this preprocess part we only care to initialize the cells that are
// used in formulas but that does not have a definition in xml
const trackedCells = new Map()
// stores information about the content of cells
const parsedCells = new Map()
const dynamicParts = {
rows: new Map(),
// NOTE: we are not using the .blocks for anything yet, BUT we can use it
// to validate that there are not block calls outside of cells other than loops later
blocks: [],
openBlocks: [],
loops: [],
openLoops: []
}
// stores the cell refs that a row contains
const cellRefsByRowMap = new Map()
// store the cell refs that are part of a loop hierarchy but that do not have a
// definition in the template, we need to track these refs to be able to
// resolve the formulas correctly
const nonExistingCellRefsByLoopHierarchy = new Map()
// stores information about the range of elements that are either static or
// are dynamic (contain handlebars tags)
const dataRanges = []
// stores information about the row elements of the template sheet (xml nodes),
// it saves attributes (with optimization for duplicated values), children
// nodes (just cells), and extra metadata that is going to be needed to recreate the nodes
// during xml template generation. these elements are the base data that is going to
// be used for elements that are generated dynamically as result of handlebars evaluation
const templateItems = createTemplateItems()
// a map to easily get the index of row elements in the templateItems elements
// by its row number
templateItems.rowNumberElementIdxMap = new Map()
const formulasToValidate = []
let rowIdx = -1
for (const sheetDataChildEl of sheetDataChildEls) {
if (sheetDataChildEl.nodeName !== 'row') {
// NOTE: for now we ignore non row elements inside sheetData
// (according to spec there is not more elements supported on sheetData other than row),
// if needed we can add code to replicate those elements
// storeElement(templateItems.data, sheetDataChildEl, {
// defaults: { type: sheetDataChildEl.nodeName }
// }, sheetDataChildEl.nodeName, templateItems.elementTypeAttributesMap)
// storeDataRange(dataRanges, dynamicParts.openBlocks.length > 0 ? 'dynamic' : 'static', templateItems.data.length - 1)
continue
}
const rowEl = sheetDataChildEl
rowIdx++
const isLastRow = rowIdx === lastRowIdx
let rowNumber = rowEl.getAttribute('r')
if (rowNumber == null || rowNumber === '') {
throw new Error('Expected row to contain r attribute defined')
}
rowNumber = parseInt(rowNumber, 10)
const rowElementItem = storeElement(templateItems.data, rowEl, {
idProp: {
name: 'r',
value: () => rowNumber
},
// we store the "r" attribute but just as empty, we just care to keep its
// position in attribute list
emptyAttrs: ['r'],
excludeChildren: true
}, 'row', templateItems.elementTypeAttributesMap)
const rowElementIdx = templateItems.data.length - 1
templateItems.elementMetaMap.set(rowElementItem, {
columnLetterChildrenMap: new Map()
})
if (mergeCellRangesByStartRowNumberMap.has(rowNumber)) {
const mergeStartLetterMap = mergeCellRangesByStartRowNumberMap.get(rowNumber)
templateItems.elementMetaMap.get(rowElementItem).mergeStartLetterMap = mergeStartLetterMap
}
templateItems.rowNumberElementIdxMap.set(rowNumber, rowElementIdx)
let rowHasDynamicContent = false
let rowIsPartOfLoopDefinition = dynamicParts.openLoops.length > 0
const rowChildEls = nodeListToArray(rowEl.childNodes)
if (!cellRefsByRowMap.has(rowNumber)) {
cellRefsByRowMap.set(rowNumber, [])
}
for (const openLoop of dynamicParts.openLoops) {
openLoop.rows.add(rowNumber)
}
for (const rowChildEl of rowChildEls) {
if (rowElementItem.children == null) {
rowElementItem.children = []
}
if (rowChildEl.nodeName !== 'c') {
// NOTE: for now we ignore non c elements inside row
// (according to spec the allowed children of a row are: c, extLst),
// if needed we can add code to replicate those elements
// storeElement(rowElementItem.children, rowChildEl, {
// defaults: { type: rowChildEl.nodeName }
// }, rowChildEl.nodeName, templateItems.elementTypeAttributesMap)
continue
}
const cellEl = rowChildEl
const cellRef = cellEl.getAttribute('r')
const parsedCellRef = parseCellRef(cellRef)
const cellMetadata = {}
cellRefsByRowMap.get(rowNumber).push(cellRef)
// search if we need to update some calc cell
const calcChainElementIdx = calcChainRefElementIdxMap.get(`${sheetInfo.id}-${cellRef}`)
if (calcChainElementIdx != null) {
cellMetadata.calcChainElementIdx = calcChainElementIdx
}
// search if we need to update table ref
const tableRef = tableParts.refByCellRefs.get(cellRef)
if (tableRef != null) {
cellMetadata.tableRef = tableRef
}
const styleId = cellEl.getAttribute('s')
if (isAutofitConfigured && styleId != null) {
cellMetadata.styleId = styleId
}
const parsedCellEntry = {
// start with the cell as static
type: 'static',
letter: parsedCellRef.letter,
columnNumber: parsedCellRef.columnNumber
}
const info = getCellInfo(cellEl, sharedStringsEls, sheetFilepath)
parsedCells.set(cellRef, parsedCellEntry)
let blockPartFound = false
let excludeCellItemChildren = false
let calculateWidthSize = isAutofitConfigured
if (
(info?.type === 'inlineStr' ||
info?.type === 's')
) {
const openTags = matchWithGlobalRegExp(info.value, openTagRegexp)
const closingTags = matchWithGlobalRegExp(info.value, closeTagRegexp)
if ((openTags.length > 0 && openTags.length !== closingTags.length)) {
// incomplete handlebars tag detected in cell value, throw error
throw new Error(`Handlebars Parse error in cell "${cellRef}" of sheet "${sheetInfo.name}". Invalid syntax detected for text: ${info.value}`)
}
if (openTags.length > 0) {
// we dont calculate the width size for cells with dynamic content
// we are going to take into account these cells at runtime
calculateWidthSize = false
parsedCellEntry.type = 'dynamic'
parsedCellEntry.textDetails = info.extra.textDetails
rowHasDynamicContent = true
// for these cells we dont store the children as static
// because we will do content detection at runtime and determine
// the cell content there
excludeCellItemChildren = true
const handlebarsTag = /{{{{0,2}|}}}{0,2}/
let remainingToCheck = info.value
let lastProcessedIdx
let lastCallPart
do {
const match = remainingToCheck.match(handlebarsTag)
const currentLoopDetected = dynamicParts.openLoops[dynamicParts.openLoops.length - 1]
if (match != null) {
const partType = match[0].startsWith('{{') ? 'start' : 'end'
if (lastProcessedIdx == null) {
// initialize variable on first match
lastProcessedIdx = 0
}
const lastPartIdx = match.index + match[0].length
const restOfStr = remainingToCheck.slice(match.index + match[0].length)
const nextCharacter = restOfStr[0] ?? ''
if (partType === 'start' && nextCharacter === '/') {
const block = getLastOpenBlock(dynamicParts)
dynamicParts.openBlocks.pop()
blockPartFound = true
lastCallPart = {
type: 'blockEnd',
bracketCount: match[0].length,
value: remainingToCheck.slice(match.index, match.index + match[0].length),
valueStartIdxInContent: lastProcessedIdx + match.index
}
block.parts.push({ cellRef })
} else if (partType === 'start' && nextCharacter === '#') {
blockPartFound = true
lastCallPart = {
type: 'blockStart',
bracketCount: match[0].length,
value: remainingToCheck.slice(match.index, match.index + match[0].length),
valueStartIdxInContent: lastProcessedIdx + match.index
}
const parentBlock = getLastOpenBlock(dynamicParts)
const newBlock = {
parts: [{ cellRef }],
children: []
}
let newBlockIdx
if (parentBlock) {
parentBlock.children.push(newBlock)
parentBlock.parts.push({ childrenIdx: parentBlock.children.length - 1 })
newBlockIdx = parentBlock.children.length - 1
} else {
dynamicParts.blocks.push(newBlock)
newBlockIdx = dynamicParts.blocks.length - 1
}
dynamicParts.openBlocks.push(newBlockIdx)
} else if (partType === 'start') {
lastCallPart = null
} else if (partType === 'end' && lastCallPart) {
if (lastCallPart.bracketCount !== match[0].length) {
throw new Error(`Handlebars Parse error in cell "${cellRef}" of sheet "${sheetInfo.name}". Mismatched handlebars brackets detected for text: ${info.value}`)
}
lastCallPart.value += remainingToCheck.slice(0, match.index + match[0].length)
let extractNameEvaluation
if (lastCallPart.type === 'blockStart') {
// skip handlebars tag and take into account the "#" character
extractNameEvaluation = {
text: lastCallPart.value.slice(lastCallPart.bracketCount + 1),
regExp: /[ }]/
}
} else if (lastCallPart.type === 'blockEnd') {
// skip handlebars tag and take into account the "/" character
extractNameEvaluation = {
text: lastCallPart.value.slice(lastCallPart.bracketCount + 1),
regExp: /}/
}
}
if (extractNameEvaluation) {
const toEvaluate = extractNameEvaluation.text
const endOfBlockNameMatch = toEvaluate.match(extractNameEvaluation.regExp)
if (endOfBlockNameMatch == null) {
throw new Error(`Handlebars Parse error in cell "${cellRef}" of sheet "${sheetInfo.name}". Invalid block helper syntax detected for text: ${info.value}`)
}
lastCallPart.name = toEvaluate.slice(0, endOfBlockNameMatch.index)
}
if (currentLoopDetected != null && lastCallPart.type === 'blockEnd' && lastCallPart.name === 'each') {
// 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.cellRef === cellRef
) {
// inline loop here, we just remove it
dynamicParts.loops.pop()
dynamicParts.openLoops.pop()
} else {
let targetLoopDetected = currentLoopDetected
if (targetLoopDetected.type === 'vertical') {
targetLoopDetected = null
for (let openLoopIdx = 0; openLoopIdx < dynamicParts.openLoops.length; openLoopIdx++) {
const openLoop = dynamicParts.openLoops[openLoopIdx]
if (openLoop.type === 'vertical' && parsedCellRef.columnNumber === openLoop.start.columnNumber) {
targetLoopDetected = openLoop
dynamicParts.openLoops.splice(openLoopIdx, 1)
break
}
}
if (!targetLoopDetected) {
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`)
}
} else {
dynamicParts.openLoops.pop()
}
targetLoopDetected.end = {
cellRef,
rowNumber,
columnNumber: parsedCellRef.columnNumber,
letter: parsedCellRef.letter,
helperCall: lastCallPart.value,
helperCallStartIdx: lastCallPart.valueStartIdxInContent
}
// add loop reference to start and end cells, we insert the ref
// on loop end to avoid adding inner loops
for (const currentCellEntry of [
parsedCells.get(targetLoopDetected.start.cellRef),
parsedCellEntry
]) {
if (currentCellEntry.loops == null) {
currentCellEntry.loops = new Set()
}
// this stores references to loops that one of its parts
// are in the cell (either the call for the start or end of loop)
currentCellEntry.loops.add(targetLoopDetected)
}
if (
targetLoopDetected.type === 'block' &&
targetLoopDetected.end.rowNumber === targetLoopDetected.start.rowNumber
) {
targetLoopDetected.type = 'row'
}
}
} else if (lastCallPart.type === 'blockStart' && lastCallPart.name === 'each') {
const isNested = currentLoopDetected != null && currentLoopDetected.type !== 'vertical'
const hierarchyIdPrefix = isNested ? `${currentLoopDetected.hierarchyId}#` : ''
const hierarchyIdCounter = isNested ? currentLoopDetected.children.length : dynamicParts.loops.length
const hierarchyId = `${hierarchyIdPrefix}${hierarchyIdCounter}`
rowIsPartOfLoopDefinition = true
const newLoopItem = {
type: 'block',
hierarchyId,
// all the rows that are part between the start and end of the loop
rows: new Set([rowNumber]),
children: [],
start: {
cellRef,
rowNumber,
columnNumber: parsedCellRef.columnNumber,
letter: parsedCellRef.letter,
helperCall: lastCallPart.value,
helperCallStartIdx: lastCallPart.valueStartIdxInContent
}
}
if (isNested) {
currentLoopDetected.children.push(newLoopItem)
}
dynamicParts.loops.push(newLoopItem)
dynamicParts.openLoops.push(newLoopItem)
if (lastCallPart.value.includes('cells=')) {
newLoopItem.type = 'dynamic'
} else if (lastCallPart.value.includes('vertical=')) {
newLoopItem.type = 'vertical'
}
}
lastCallPart = null
}
lastProcessedIdx += lastPartIdx
remainingToCheck = restOfStr
} else {
remainingToCheck = ''
}
} while (remainingToCheck !== '')
}
} else if (
info?.type === 'str'
) {
cellMetadata.formula = {
value: info.value
}
if (info.extra.formulaEl.hasAttributes()) {
cellMetadata.formula.attributes = new Map()
const attributeList = Array.from(info.extra.formulaEl.attributes)
for (const attr of attributeList) {
cellMetadata.formula.attributes.set(attr.name, attr.value)
}
}
const isSharedFormula = (
info.extra.formulaEl.getAttribute('t') === 'shared' &&
info.extra.formulaEl.getAttribute('si') != null &&
info.extra.formulaEl.getAttribute('si') !== ''
)
if (isSharedFormula) {
const ref = info.extra.formulaEl.getAttribute('ref')
cellMetadata.formula.shared = {
type: ref != null && ref !== '' ? 'source' : 'reference'
}
if (cellMetadata.formula.shared.type === 'source') {
cellMetadata.formula.shared.sourceRef = info.extra.formulaEl.getAttribute('ref')
}
}
const { cellRefs } = evaluateCellRefsFromExpression(cellMetadata.formula.value)
// the cell refs used in formula
cellMetadata.formula.cellRefs = cellRefs
formulasToValidate.push(cellMetadata.formula)
}
if (calculateWidthSize) {
const fontSize = getFontSizeFromStyle(styleId, styleInfo)
const currentMaxSize = autoFitData.cols.get(parsedCellRef.letter)
// for formulas we use the cached result of formula if exists
const targetValue = info.type === 'str' ? info.extra.cachedValue : info.value
if (targetValue != null) {
const currentSize = getPixelWidthOfValue(targetValue, fontSize)
if (currentMaxSize == null || currentSize > currentMaxSize) {
autoFitData.cols.set(parsedCellRef.letter, currentSize)
}
}
}
if (!blockPartFound && dynamicParts.openBlocks.length > 0) {
const lastHbOpenBlock = getLastOpenBlock(dynamicParts)
lastHbOpenBlock.parts.push({ cellRef })
}
const cellElementItem = storeElement(rowElementItem.children, cellEl, {
// we store the "r" attribute but just as empty, we just care to keep its
// position in attribute list
emptyAttrs: ['r'],
excludeChildren: excludeCellItemChildren
}, 'c', templateItems.elementTypeAttributesMap)
if (Object.keys(cellMetadata).length > 0) {
templateItems.elementMetaMap.set(cellElementItem, cellMetadata)
}
templateItems.elementMetaMap.get(rowElementItem).columnLetterChildrenMap.set(
parsedCellRef.letter,
rowElementItem.children.length - 1
)
}
const loopsToValidate = checkAndGetLoopsToProcess(f.path, dynamicParts.loops, rowNumber, isLastRow)
for (const currentLoop of loopsToValidate) {
if (currentLoop.type === 'dynamic') {
const invalidLoop = dynamicParts.loops.find((loop) => {
// skip if it is the same
if (loop.hierarchyId === currentLoop.hierarchyId) {
return false
}
if (loop.type === 'vertical' || loop.type === 'dynamic') {
// we are fine detecting just one side
return (
loop.start.columnNumber === currentLoop.start.columnNumber &&
loop.start.rowNumber >= currentLoop.start.rowNumber &&
loop.start.rowNumber <= currentLoop.end.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.rowNumber >= currentLoop.start.rowNumber &&
loop.start.rowNumber <= currentLoop.end.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 cell definition in ${f.path}, cell ${invalidLoop.start.cellRef}`)
}
}
} else if (currentLoop.type === 'vertical') {
if (currentLoop.start.columnNumber !== currentLoop.end.columnNumber) {
throw new Error(`Vertical loops must start {{#each}} and end {{/each}} in the same column. Check Vertical loop definition in ${f.path}, cell ${currentLoop.start.cellRef}, ${currentLoop.end.cellRef}`)
}
const invalidLoop = dynamicParts.loops.find((loop) => {
// skip if it is the same
if (loop.hierarchyId === currentLoop.hierarchyId) {
return false
}
if (loop.type === 'vertical' || loop.type === 'dynamic') {
// we are fine detecting just one side
return (
loop.start.columnNumber === currentLoop.start.columnNumber &&
loop.start.rowNumber >= currentLoop.start.rowNumber &&
loop.start.rowNumber <= currentLoop.end.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.rowNumber >= currentLoop.start.rowNumber &&
loop.start.rowNumber <= currentLoop.end.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}`)
}
}
} else {
if (currentLoop.type === 'row') {
const invalidLoop = dynamicParts.loops.find((loop) => {
if (loop.hierarchyId === currentLoop.hierarchyId) {
return false
}
if (loop.type === 'dynamic') {
// we are fine detecting just one side
return (
loop.start.columnNumber === currentLoop.start.columnNumber &&
loop.start.rowNumber >= currentLoop.start.rowNumber &&
loop.start.rowNumber <= currentLoop.end.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}`)
}
}
}
}
}
const addToDynamicRange = rowHasDynamicContent || rowIsPartOfLoopDefinition
if (addToDynamicRange) {
const dynamicRowEntry = { cellRefs: [...cellRefsByRowMap.get(rowNumber)] }
dynamicParts.rows.set(rowNumber, dynamicRowEntry)
}
storeDataRange(dataRanges, addToDynamicRange ? 'dynamic' : 'static', templateItems.data.length - 1)
}
for (const formula of formulasToValidate) {
for (const cellRefInfo of formula.cellRefs) {
// we don't check 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 cellExists = parsedCells.has(normalizedCellRef)
if (cellExists) {
continue
}
const parsedNormalizedCellRef = parseCellRef(normalizedCellRef)
const loopDetectionResult = getParentLoop(dynamicParts.loops, {
rowNumber: parsedNormalizedCellRef.rowNumber,
columnNumber: parsedNormalizedCellRef.columnNumber
})
const trackedCell = {
first: normalizedCellRef,
last: normalizedCellRef,
count: 0
}
// initialize non existing cell ref used in formula
trackedCells.set(normalizedCellRef, trackedCell)
if (loopDetectionResult != null) {
const loopHierarchyId = loopDetectionResult.loopDetected.hierarchyId
let collection
// store the non existing cell refs used in formulas that are
// part of loops
if (nonExistingCellRefsByLoopHierarchy.has(loopHierarchyId)) {
collection = nonExistingCellRefsByLoopHierarchy.get(loopHierarchyId)
} else {
collection = []
nonExistingCellRefsByLoopHierarchy.set(loopHierarchyId, collection)
}
collection.push(normalizedCellRef)
// we set empty string here, just as a signal that this is going to be set
// at runtime
trackedCell.currentLoopId = ''
trackedCell.fromNonExistingLoopHierarchyId = loopHierarchyId
}
}
}
if (dynamicParts.openLoops.length > 0) {
const loopInfoCalls = dynamicParts.openLoops.map((l) => `- ${l.type} loop starting at cell ${l.start.cellRef}`)
throw new Error(`Unable to find end of loop ({{/each}}) for the following loop calls in ${f.path}:\n${loopInfoCalls.join('\n')}`)
}
// simplify the elements attribute data structure
for (const [elementType, elementAttributes] of templateItems.elementTypeAttributesMap) {
templateItems.elementTypeAttributesMap.set(elementType, elementAttributes.data)
}
const dataTemplate = generateDataTemplate(
dataRanges,
templateItems,
dynamicParts,
parsedCells,
autoFitConfigured
)
sharedData.fileDataMap.set(f.path, {
sheet: {
id: sheetInfo.id,
name: sheetInfo.name
},
dataTemplate,
templateItems,
runtime: {
style: {
info: styleInfo,
fontSizeCache: new Map()
},
dimension: parsedDimension,
autoFit: autoFitData,
loops: {
data: [],
evaluated: [],
nonExistingCellRefsByLoopHierarchy
},
trackedCells,
lazyFormulas: {
lastSeq: null,
pending: {
notCompletedLoops: new Map(),
cellsToFormulaIds: new Map()
},
data: new Map()
},
tables: tableParts
},
mergeCellItems: [],
dataItems: []
})
// if (f.path === 'xl/worksheets/sheet1.xml') {
// debugger
// }
addEndCallback(() => {
// set that this workbook should perform a full
// recalculation when the workbook is opened
if (workbookCalcPrEl) {
workbookCalcPrEl.setAttribute('fullCalcOnLoad', '1')
}
// update dimension if needed
if (dimensionEl) {
dimensionEl.setAttribute('ref', getDataHelperCall('dimension', null, { isBlock: false }))
}
// replacing <mergeCells> with a helper call that will generate the final merge cells definitions
if (mergeCellsEl) {
mergeCellsEl.parentNode.replaceChild(
processOpeningTag(
sheetDoc,
false,
getDataHelperCall('mergeCells', null, { isBlock: false })
),
mergeCellsEl
)
}
// update calcChainDoc if needed
if (calcChainDoc) {
const cloneWithoutChildren = calcChainDoc.documentElement.cloneNode()
// fast way to remove children, iterating all children and using .removeChild is
// very slow in big documents
calcChainDoc.replaceChild(cloneWithoutChildren, calcChainDoc.documentElement)
calcChainDoc.documentElement.appendChild(
processOpeningTag(
calcChainDoc,
false,
getDataHelperCall('calcChain', null, { isBlock: false })
)
)
}
// update table refs if needed
if (tableParts.refSourceEls.length > 0) {
for (const refSourceEl of tableParts.refSourceEls) {
const rangeRef = refSourceEl.getAttribute('ref')
const refMeta = tableParts.refsMeta.get(rangeRef)
refSourceEl.setAttribute('ref', `{{@${refMeta.dataVariableName}}}`)
}
delete tableParts.refByCellRefs
// remove dom references
delete tableParts.refSourceEls
}
// if autofit is configured, we are going to customize the cols so
// we need to wrap it in helper
if (isAutofitConfigured) {
// if there is no <cols> we initialize it
if (colsEl == null) {
colsEl = sheetDoc.createElement('cols')
sheetDataEl.parentNode.insertBefore(colsEl, sheetDataEl)
}
processOpeningTag(sheetDoc, colsEl, getDataHelperCall('cols'))
processClosingTag(sheetDoc, colsEl, '{{/_D}}')
// remove {{xlsxColAutofit}} calls and remove comments and shapes from
// their respective documents if needed
for (const conf of autoFitConfigured) {
const tEls = nodeListToArray(conf.commentEl.getElementsByTagName('t'))
let shouldRemoveComment = false
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)
} else {
// when comment is not going to be removed we remove just the helper call text
// and if there is no more text in the element remove it
const tCallEl = tEls.find((tEl) => tEl.textContent.startsWith('{{xlsxColAutofit'))
tCallEl.textContent = tCallEl.textContent.replace(/{{xlsxColAutofit( [^}]*)?}}/, '')
if (tCallEl.textContent === '') {
tCallEl.parentNode.removeChild(tCallEl)
}
}
}
}
// replacing sheetData with a helper call that will re-create the final row and cell tags
sheetDataEl.parentNode.replaceChild(
processOpeningTag(
sheetDoc,
false,
getDataHelperCall('sd', null, { isBlock: false })
),
sheetDataEl
)
})
}
}
function createTemplateItems () {
const templateItems = {
data: [],
// the idea is that on this map we can store metadata for any element on the template
// (even children elements)
elementMetaMap: new WeakMap(),
elementTypeAttributesMap: new Map()
}
return templateItems
}
function getParentLoop (loopsDetected, cellNumbers) {
let loopDetected
// 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)
for (let index = loopsDetected.length - 1; index >= 0; index--) {
const l = loopsDetected[index]
let match = false
switch (l.type) {
case 'row':
case 'dynamic':
match = l.start.rowNumber === cellNumbers.rowNumber
break
case 'block':
match = (
cellNumbers.rowNumber >= l.start.rowNumber &&
cellNumbers.rowNumber <= l.end.rowNumber
)
break
case 'vertical':
match = (
cellNumbers.rowNumber >= l.start.rowNumber &&
cellNumbers.rowNumber <= l.end.rowNumber &&
cellNumbers.columnNumber === l.start.columnNumber
)
break
default:
throw new Error(`Unknown loop type ${l.type}`)
}
if (match) {
loopDetected = l
break
}
}
if (loopDetected == null) {
return
}
const loopStart = loopDetected.start
const loopEnd = loopDetected.end
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 = (
cellNumbers.columnNumber >= loopStart.columnNumber &&
cellNumbers.columnNumber <= loopEnd.columnNumber
)
break
}
case 'block': {
if (loopStart.rowNumber === cellNumbers.rowNumber) {
insideLoop = cellNumbers.columnNumber >= loopStart.columnNumber
} else if (loopEnd.rowNumber === cellNumbers.rowNumber) {
insideLoop = cellNumbers.columnNumber <= loopEnd.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,
isInside: insideLoop
}
}
function checkAndGetLoopsToProcess (currentFilePath, loopsDetected, currentRowNumber, isLastRow) {
const dynamicLoops = loopsDetected.filter((l) => l.type === 'dynamic' && l.start.rowNumber === currentRowNumber)
const invalidDynamicLoops = dynamicLoops.find((l) => l.end == null)
if (invalidDynamicLoops != null) {
throw new Error(`Unable to find end of dynamic loop (#each) in ${currentFilePath}. {{/each}} is missing`)
}
const rowLoops = loopsDetected.filter((l) => l.type === 'row' && l.start.rowNumber === currentRowNumber)
const invalidRowLoop = rowLoops.find((l) => l.end == null)
if (invalidRowLoop != null) {
throw new Error(`Unable to find end of loop (#each) in ${currentFilePath}. {{/each}} is missing`)
}
let blockLoops = loopsDetected.filter((l) => l.type === 'block')
if (isLastRow) {
const invalidBlockLoop = blockLoops.find((l) => l.end == null)
if (invalidBlockLoop) {
throw new Error(`Unable to find end of block loop (#each) in ${currentFilePath}. {{/each}} is missing`)
}
}
blockLoops = blockLoops.filter((l) => l.end?.rowNumber === currentRowNumber)
let verticalLoops = loopsDetected.filter((l) => l.type === 'vertical')
if (isLastRow) {
const invalidVerticalLoop = verticalLoops.find((l) => l.end == null)
if (invalidVerticalLoop) {
throw new Error(`Unable to find end of vertical loop (#each) in ${currentFilePath}. {{/each}} is missing`)
}
}
verticalLoops = verticalLoops.filter((l) => l.end?.rowNumber === currentRowNumber)
return [...dynamicLoops, ...rowLoops, ...blockLoops, ...verticalLoops]
}
function processOpeningTag (doc, refElement, helperCall) {
const fakeElement = doc.createElement('xlsxRemove')
fakeElement.textContent = helperCall
if (refElement !== false) {
refElement.parentNode.insertBefore(fakeElement, refElement)
}
return fakeElement
}
function processClosingTag (doc, refElement, closeCall) {
const fakeElement = doc.createElement('xlsxRemove')
fakeElement.textContent = closeCall
if (refElement !== false) {
refElement.parentNode.insertBefore(fakeElement, refElement.nextSibling)
}
return fakeElement
}
function matchWithGlobalRegExp (str, regexp) {
// reset state of global regexp
regexp.lastIndex = 0
const a = []
let m
// eslint-disable-next-line no-cond-assign
while (m = regexp.exec(str)) {
a.push({
index: m.index,
offset: regexp.lastIndex
})
}
// reset state of global regexp
regexp.lastIndex = 0
return a
}
function storeDataRange (dataRanges, type, idx) {
let lastRange = dataRanges[dataRanges.length - 1]
if (lastRange == null || lastRange.type !== type) {
lastRange = {
type,
start: idx
}
dataRanges.push(lastRange)
}
lastRange.end = idx
}
function storeElement (elements, baseElement, baseMeta, baseElementType, elementTypeAttributes) {
const pending = [{ container: elements, type: baseElementType, meta: baseMeta, element: baseElement }]
let baseNewElement
while (pending.length > 0) {
const { container, type, meta, element } = pending.shift()
const newElement = processElement(container, element, meta, type, elementTypeAttributes)
if (baseNewElement == null) {
baseNewElement = newElement
}
const excludeChildren = meta?.excludeChildren ?? false
if (excludeChildren) {
continue
}
const childEls = nodeListToArray(element.childNodes ?? []).filter((node) => {
// we only care about element, text and comment nodes
return node.nodeType === 1 || node.nodeType === 3 || node.nodeType === 8
})
if (childEls.length > 0) {
newElement.children = []
pending.unshift(...childEls.map((childEl) => ({
container: newElement.children,
type: childEl.nodeName,
meta: {
defaults: { type: childEl.nodeName }
},
element: childEl
})))
}
}
return baseNewElement
}
function processElement (elements, element, meta, elementType, elementTypeAttributes) {
const newElementDefaults = meta?.defaults || {}
const excludeAttrs = meta?.excludeAttrs || []
const emptyAttrs = meta?.emptyAttrs || []
const elementMetadata = {
...newElementDefaults
}
elements.push(elementMetadata)
if (elementType === '#text' || elementType === '#comment') {
elementMetadata.value = element.nodeValue
}
let idPropName
let getIdPropValue
if (meta.idProp) {
if (typeof meta.idProp === 'string') {
idPropName = meta.idProp
getIdPropValue = (_value) => _value
} else {
idPropName = meta.idProp.name
getIdPropValue = meta.idProp.value
}
}
const attributesList = nodeListToArray(element.attributes ?? [])
for (const attr of attributesList) {
if (idPropName === attr.name) {
elementMetadata.id = getIdPropValue(attr.value)
}
if (excludeAttrs.includes(attr.name)) {
continue
}
if (elementMetadata.attributes == null) {
elementMetadata.attributes = new Map()
}
let elementAttributes = elementTypeAttributes.get(elementType)
if (elementAttributes == null) {
elementAttributes = {
nameIndexMap: new Map(),
data: []
}
elementTypeAttributes.set(elementType, elementAttributes)
}
const attributeIndexes = []
let [attributeNameIdx, valueIndexMap] = elementAttributes.nameIndexMap.get(attr.name) ?? []
let attributeValues
if (attributeNameIdx == null) {
attributeNameIdx = elementAttributes.data.length
attributeValues = []
valueIndexMap = new Map()
elementAttributes.nameIndexMap.set(attr.name, [attributeNameIdx, valueIndexMap])
elementAttributes.data.push([attr.name, attributeValues])
} else {
attributeValues = elementAttributes.data[attributeNameIdx][1]
}
attributeIndexes.push(attributeNameIdx)
let valueIdx = valueIndexMap.get(emptyAttrs.includes(attr.name) ? '' : attr.value)
if (valueIdx == null) {
valueIdx = attributeValues.length
valueIndexMap.set(attr.value, valueIdx)
attributeValues.push(attr.value)
}
attributeIndexes.push(valueIdx)
elementMetadata.attributes.set(attributeIndexes[0], attributeIndexes[1])
}
return elementMetadata
}
function getLastOpenBlock (dynamicParts) {
let container = dynamicParts.blocks
let l