@jsreport/jsreport-xlsx
Version:
jsreport recipe rendering excels directly from open xml
475 lines (381 loc) • 15.5 kB
JavaScript
const { DOMParser } = require('@xmldom/xmldom')
const { decode } = require('html-entities')
const { col2num, num2col } = require('xlsx-coordinates')
const pixelWidth = require('string-pixel-width')
const CELL_REG_REGEXP = /^(?:('?(?:\[([A-Za-z0-9_. ]+\.xlsx)\])?([A-Za-z0-9_. ]+)'?)!)?(\$?[A-Z]+)(\$?\d+)$/
// allows to parse cell expressions like:
// - B6, $B6 (locked column), B$6 (locked row), $B$6 (locked column and row)
// - B6:B10 (ranges) and variants with locked columns and rows
// - SheetName!B6:B10, 'SheetName'!B6:B10 (sheet name reference) and variants with locked columns and rows
// - [WorkbookName.xlsx]SheetName!B6:B10, '[WorkbookName.xlsx]SheetName'!B6:B10 (workbook name reference) and variants with locked columns and rows
// - [WorkbookName.xlsx]SheetName!B6:[WorkbookName.xlsx]SheetName!B10, '[WorkbookName.xlsx]SheetName'!B6:'[WorkbookName.xlsx]SheetName'!B10 (workbook name reference) and variants with locked columns and rows
// result: <complete ref from left>:<complete ref from right>
// it is returned from a fn because it needs to use the global flag on regexp,
// and we can not reuse the same regexp instance safely, it needs to be a new one for execution
const getRangeOrMultiCellRefExpressionRegexp = () => /((?:'?(?:\[(?:[A-Za-z0-9_. ]+\.xlsx)\])?(?:[A-Za-z0-9_. ]+)'?!)?(?:\$?[A-Z]+\$?\d+:))?((?:'?(?:\[(?:[A-Za-z0-9_. ]+\.xlsx)\])?(?:[A-Za-z0-9_. ]+)'?!)?(?:\$?[A-Z]+\$?\d+))/g
function parseCellRef (cellRef, parsedStartCellRef) {
// parses single cell ref like:
// B6, $B6 (locked column), B$6 (locked row), $B$6 (locked column and row)
// SheetName!B6, 'SheetName'!B6 (sheet name reference) and variants with locked columns and rows
// [WorkbookName.xlsx]SheetName!B6, '[WorkbookName.xlsx]SheetName'!B6 (workbook name reference) and variants with locked columns and rows
const matches = cellRef.match(CELL_REG_REGEXP)
if (matches == null || matches[4] == null) {
throw new Error(`"${cellRef}" is not a valid cell reference`)
}
const lockedColumn = matches[4].startsWith('$')
const lockedRow = matches[5].startsWith('$')
const letter = lockedColumn ? matches[4].slice(1) : matches[4]
let ownWorkbookName
let ownSheetName
let ownSensitiveName = false
let workbookName
let sheetName
let sensitiveName
if (matches[3] != null) {
ownWorkbookName = matches[2]
ownSheetName = matches[3]
ownSensitiveName = matches[1].startsWith('\'')
}
workbookName = ownWorkbookName
sheetName = ownSheetName
sensitiveName = ownSensitiveName
if (matches[3] == null && parsedStartCellRef != null) {
workbookName = parsedStartCellRef.workbookName
sheetName = parsedStartCellRef.sheetName
sensitiveName = parsedStartCellRef.sensitiveName
}
return {
workbookName,
sheetName,
sensitiveName,
ownMetadata: {
workbookName: ownWorkbookName,
sheetName: ownSheetName,
sensitiveName: ownSensitiveName
},
letter,
lockedColumn,
columnNumber: col2num(letter) + 1,
lockedRow,
rowNumber: parseInt(lockedRow ? matches[5].slice(1) : matches[5], 10)
}
}
function getNewCellLetter (cellLetter, increment) {
if (typeof increment !== 'number' || isNaN(increment)) {
throw new Error('Increment must be a number')
}
const colNumber = col2num(cellLetter) + increment
if (colNumber < 0) {
throw new Error('Column number can not be negative')
}
return num2col(colNumber)
}
function getPixelWidthOfValue (value, fontSize) {
const fontSizeInPx = fontSize * (96 / 72)
const size = pixelWidth(value, { font: 'Arial', size: fontSizeInPx })
return parseFloat(size.toFixed(2))
}
function getFontSizeFromStyle (_styleId, styleInfo, cache) {
let styleId
if (_styleId != null && _styleId !== '') {
styleId = parseInt(_styleId, 10)
} else {
styleId = 0
}
const cacheKey = styleId.toString()
if (cache != null && cache.has(cacheKey)) {
return cache.get(cacheKey)
}
const { cellXfsEls, cellStyleXfsEls, fontEls } = styleInfo
const selectedXfEl = cellXfsEls[styleId]
let fontId = selectedXfEl.getAttribute('fontId')
const applyFont = selectedXfEl.getAttribute('applyFont')
const xfId = selectedXfEl.getAttribute('xfId')
if (
applyFont == null ||
applyFont === '' ||
applyFont === '0'
) {
const selectedStyleXfEl = cellStyleXfsEls[xfId]
const nestedFontId = selectedStyleXfEl.getAttribute('fontId')
const nestedApplyFont = selectedStyleXfEl.getAttribute('applyFont')
if (
nestedApplyFont == null ||
nestedApplyFont === '' ||
nestedApplyFont === '1'
) {
fontId = nestedFontId
}
}
const fontEl = fontEls[fontId]
const sizeEl = fontEl.getElementsByTagName('sz')[0]
// size stored in xlsx is in pt
const fontSize = parseFloat(sizeEl.getAttribute('val'))
if (cache != null) {
cache.set(cacheKey, fontSize)
}
return fontSize
}
function evaluateCellRefsFromExpression (valueExpr, replacer) {
const cellRefs = []
const newValueExpr = valueExpr.replace(getRangeOrMultiCellRefExpressionRegexp(), (...args) => {
const [, _startingCellRef, endingCellRef] = args
const isRange = _startingCellRef != null
const cellsRefsToUpdate = []
if (isRange) {
const startingCellRef = _startingCellRef.slice(0, -1)
const parsedStartingCellRef = parseCellRef(startingCellRef)
const parsedEndingCellRef = parseCellRef(endingCellRef, parsedStartingCellRef)
cellsRefsToUpdate.push({
type: 'rangeStart',
ref: startingCellRef,
parsed: parsedStartingCellRef,
parsedRangeStart: parsedStartingCellRef,
parsedRangeEnd: parsedEndingCellRef
})
cellsRefsToUpdate.push({
type: 'rangeEnd',
ref: endingCellRef,
parsed: parsedEndingCellRef,
parsedRangeStart: parsedStartingCellRef,
parsedRangeEnd: parsedEndingCellRef
})
} else {
const parsedEndingCellRef = parseCellRef(endingCellRef)
cellsRefsToUpdate.push({ type: 'standalone', ref: endingCellRef, parsed: parsedEndingCellRef })
}
const cellsRefsUpdated = []
for (let cellRefPosition = 0; cellRefPosition < cellsRefsToUpdate.length; cellRefPosition++) {
const cellRefToUpdate = cellsRefsToUpdate[cellRefPosition]
cellRefToUpdate.localRef = `${cellRefToUpdate.parsed.letter}${cellRefToUpdate.parsed.rowNumber}`
const newCellRef = replacer != null ? replacer(cellRefToUpdate, cellRefPosition) : cellRefToUpdate.ref
cellsRefsUpdated.push(newCellRef)
cellRefs.push({ ...cellRefToUpdate, newRef: newCellRef })
}
return cellsRefsUpdated.join(':')
})
return {
cellRefs,
newValue: newValueExpr
}
}
function getNewFormula (originalFormula, parsedOriginCellRef, meta) {
let newFormula
const result = {}
if (meta.type === 'normal') {
result.lazyCellRefs = {}
}
if (meta.type !== 'normal' && meta.type !== 'lazy') {
throw new Error('meta parameter must be either "normal" or "lazy"')
}
const {
originCellIsFromLoop, rowPreviousLoopIncrement, rowCurrentLoopIncrement,
columnPreviousLoopIncrement, columnCurrentLoopIncrement, trackedCells
} = meta
const { newValue } = evaluateCellRefsFromExpression(originalFormula, (cellRefInfo, cellRefPosition) => {
const originMetadata = { workbookName: parsedOriginCellRef.workbookName, sheetName: parsedOriginCellRef.sheetName }
const targetMetadata = { workbookName: cellRefInfo.parsed.workbookName, sheetName: cellRefInfo.parsed.sheetName }
const lazyCellRefId = `${cellRefInfo.localRef}@${cellRefPosition}`
if (meta.type === 'lazy') {
const { lazyCellRefs } = meta
const shouldEvaluate = lazyCellRefs[lazyCellRefId] != null
// reuse already calculated cell refs
if (!shouldEvaluate) {
return generateNewCellRefFrom(cellRefInfo.parsed, {
columnLetter: cellRefInfo.parsed.letter,
rowNumber: cellRefInfo.parsed.rowNumber
})
}
}
let isLocalRef = true
if (
originMetadata.workbookName !== targetMetadata.workbookName ||
originMetadata.sheetName !== targetMetadata.sheetName
) {
isLocalRef = false
}
let cellRefIsFromLoop = false
if (isLocalRef && trackedCells[cellRefInfo.localRef] != null) {
cellRefIsFromLoop = trackedCells[cellRefInfo.localRef].currentLoopId != null
}
let includeLoopIncrement
if (meta.type === 'normal') {
const { includeLoopIncrementResolver } = meta
includeLoopIncrement = includeLoopIncrementResolver(cellRefIsFromLoop, cellRefInfo)
} else {
const { lazyCellRefs } = meta
const found = lazyCellRefs[lazyCellRefId]
includeLoopIncrement = found != null ? found.includeLoopIncrement : false
}
let newRowNumber
let newColumnLetter
if (!isLocalRef) {
// cell references to other sheets
newColumnLetter = cellRefInfo.parsed.letter
newRowNumber = cellRefInfo.parsed.rowNumber
if (!cellRefInfo.parsed.lockedRow) {
newRowNumber += rowCurrentLoopIncrement
}
if (!cellRefInfo.parsed.lockedColumn) {
newColumnLetter = getNewCellLetter(cellRefInfo.parsed.letter, columnCurrentLoopIncrement)
}
} else if (
meta.type === 'normal' &&
(
parsedOriginCellRef.rowNumber < cellRefInfo.parsed.rowNumber ||
parsedOriginCellRef.columnNumber < cellRefInfo.parsed.columnNumber
)
) {
// if formula has a cell reference that is greater than origin then we
// mark it as lazy
result.lazyCellRefs[lazyCellRefId] = {
cellRef: cellRefInfo.localRef,
position: cellRefPosition,
includeLoopIncrement
}
// let the cell as it is
// (the final row number will be calculated later in other helper)
newRowNumber = cellRefInfo.parsed.rowNumber
newColumnLetter = cellRefInfo.parsed.letter
} else {
let columnIncrement
let rowIncrement
if (trackedCells[cellRefInfo.localRef] != null && trackedCells[cellRefInfo.localRef].count > 0) {
const { currentCellRef, getCurrentLoopItem } = meta
const tracked = trackedCells[cellRefInfo.localRef]
const shouldUseFirstForRow = (
cellRefInfo.parsed.lockedRow ||
(!originCellIsFromLoop && cellRefIsFromLoop && cellRefInfo.type === 'rangeStart')
)
let parsedLastCellRef = shouldUseFirstForRow ? parseCellRef(tracked.first) : parseCellRef(tracked.last)
rowIncrement = parsedLastCellRef.rowNumber - cellRefInfo.parsed.rowNumber
let cellRefLoopItem
if (cellRefIsFromLoop) {
cellRefLoopItem = getCurrentLoopItem(tracked.currentLoopId)
}
if (!cellRefInfo.parsed.lockedColumn && originCellIsFromLoop && cellRefLoopItem?.type === 'vertical') {
const parsedOriginCurrentCellRef = parseCellRef(currentCellRef)
parsedLastCellRef = parseCellRef(cellRefLoopItem.trackedCells.get(cellRefInfo.localRef)?.get(parsedOriginCurrentCellRef.letter) ?? currentCellRef)
} else {
const shouldUseFirstForColumn = (
cellRefInfo.parsed.lockedColumn ||
(!originCellIsFromLoop && cellRefIsFromLoop && cellRefInfo.type === 'rangeStart')
)
parsedLastCellRef = shouldUseFirstForColumn ? parseCellRef(tracked.first) : parseCellRef(tracked.last)
}
columnIncrement = parsedLastCellRef.columnNumber - cellRefInfo.parsed.columnNumber
} else {
// cell reference points to cell which does not exists as content of the template
rowIncrement = rowPreviousLoopIncrement
if (includeLoopIncrement) {
rowIncrement += rowCurrentLoopIncrement
}
columnIncrement = columnPreviousLoopIncrement
if (includeLoopIncrement) {
columnIncrement += columnCurrentLoopIncrement
}
}
newColumnLetter = getNewCellLetter(cellRefInfo.parsed.letter, columnIncrement)
newRowNumber = cellRefInfo.parsed.rowNumber + rowIncrement
}
const newCellRef = generateNewCellRefFrom(cellRefInfo.parsed, {
columnLetter: newColumnLetter,
rowNumber: newRowNumber
})
return newCellRef
})
const lazyCellRefsCount = result.lazyCellRefs != null ? Object.keys(result.lazyCellRefs).length : 0
if (meta.type === 'normal' && lazyCellRefsCount > 0) {
const { lazyFormulas, currentCellRef } = meta
lazyFormulas.count = lazyFormulas.count || 0
lazyFormulas.count += 1
lazyFormulas.data = lazyFormulas.data || {}
const lazyFormulaRefId = `$lazyFormulaRef${lazyFormulas.count}`
lazyFormulas.data[lazyFormulaRefId] = {
// the formulaCellRef here is just added for easy debugging
formulaCellRef: currentCellRef,
formula: newValue,
// just placeholder for the key, this is going to be fill at runtime
// with the updated formula
newFormula: null,
parsedOriginCellRef,
originCellIsFromLoop,
rowPreviousLoopIncrement,
rowCurrentLoopIncrement,
columnPreviousLoopIncrement,
columnCurrentLoopIncrement,
cellRefs: result.lazyCellRefs
}
newFormula = lazyFormulaRefId
} else {
newFormula = newValue
}
result.formula = newFormula
return result
}
function generateNewCellRefFrom (parsedCellRef, { columnLetter, rowNumber }, fullMetadata = false) {
let prefix = ''
const prefixData = fullMetadata
? {
workbookName: parsedCellRef.workbookName,
sheetName: parsedCellRef.sheetName,
sensitiveName: parsedCellRef.sensitiveName
}
: parsedCellRef.ownMetadata
if (prefixData.sheetName != null) {
if (prefixData.workbookName != null) {
prefix += `[${prefixData.workbookName}]`
}
prefix += prefixData.sheetName
if (prefixData.sensitiveName) {
prefix = `'${prefix}'`
}
prefix += '!'
}
const letter = columnLetter != null ? columnLetter : parsedCellRef.letter
const number = rowNumber != null ? rowNumber : parsedCellRef.rowNumber
return `${prefix}${parsedCellRef.lockedColumn ? '$' : ''}${letter}${parsedCellRef.lockedRow ? '$' : ''}${number}`
}
const xmlEscapeMap = {
'>': '>',
'<': '<',
"'": ''',
'"': '"',
'&': '&'
}
// we dont'se the encode function of html-entities because want to have the chance to
// escape just some characters
function encodeXML (str, mode = 'all') {
let pattern
switch (mode) {
case 'all':
pattern = /([&"<>'])/g
break
case 'basic':
pattern = /([&<>])/g
break
default:
throw new Error('Invalid mode for encodeXML')
}
const output = str.replace(pattern, (_, item) => {
return xmlEscapeMap[item]
})
return output
}
function decodeXML (str) {
return decode(str, { level: 'xml' })
}
function getTextContent (xml) {
const doc = new DOMParser().parseFromString(xml)
return doc.documentElement.textContent || ''
}
module.exports.parseCellRef = parseCellRef
module.exports.getNewCellLetter = getNewCellLetter
module.exports.getPixelWidthOfValue = getPixelWidthOfValue
module.exports.getFontSizeFromStyle = getFontSizeFromStyle
module.exports.evaluateCellRefsFromExpression = evaluateCellRefsFromExpression
module.exports.getNewFormula = getNewFormula
module.exports.generateNewCellRefFrom = generateNewCellRefFrom
module.exports.encodeXML = encodeXML
module.exports.decodeXML = decodeXML
module.exports.getTextContent = getTextContent