import { MalformedFileError, TemplateSyntaxError } from "src/errors"; import { OmlAttribute, OpenXmlPart, RelType, Xlsx } from "src/office"; import { xml, XmlGeneralNode, XmlNode } from "src/xml"; import { ChartColors } from "./chartColors"; import { bubbleSizeValues, Categories, ChartData, ChartType, chartTypes, formatCode, formatIds, isBubbleChartData, isScatterChartData, isStandardChartData, isStringCategories, ScatterChartData, scatterXValues, scatterYValues, StandardChartData } from "./chartData"; import { validateChartData } from "./chartDataValidation"; // Based on: https://github.com/OpenXmlDev/Open-Xml-PowerTools/blob/vNext/OpenXmlPowerTools/ChartUpdater.cs const space = " "; const xValuesTitle = "X-Values"; export async function updateChart(chartPart: OpenXmlPart, chartData: ChartData) { // Normalize the chart data: // Shallow clone and make sure series names are set. chartData = Object.assign({}, chartData); for (let i = 0; i < chartData.series.length; i++) { const ser = chartData.series[i]; chartData.series[i] = Object.assign({}, ser); chartData.series[i].name = seriesName(ser.name, i); } // Get the chart node const root = await chartPart.xmlRoot(); if (root.nodeName !== "c:chartSpace") { throw new MalformedFileError(`Unexpected chart root node "${root.nodeName}"`); } const chartWrapperNode = root.childNodes?.find(child => child.nodeName === "c:chart"); if (!chartWrapperNode) { throw new MalformedFileError("Chart node not found"); } const plotAreaNode = chartWrapperNode.childNodes?.find(child => child.nodeName === "c:plotArea"); if (!plotAreaNode) { throw new MalformedFileError("Plot area node not found"); } const chartNode = plotAreaNode.childNodes?.find(child => Object.values(chartTypes).includes(child.nodeName as any)) as XmlGeneralNode; if (!chartNode) { const plotAreaChildren = plotAreaNode.childNodes?.map(child => `<${child.nodeName}>`); const supportedChartTypes = Object.values(chartTypes).join(", "); throw new TemplateSyntaxError(`Unsupported chart type. Plot area children: ${plotAreaChildren?.join(", ")}. Supported chart types: ${supportedChartTypes}`); } const chartType = chartNode.nodeName as ChartType; // Input validation validateChartData(chartType, chartData); // Assemble the existing chart information const existingSeries = readExistingSeries(chartNode, chartData); const sheetName = existingSeries.map(ser => ser.sheetName).filter(Boolean)?.[0]; const colors = await ChartColors.load(chartPart); const existingChart: ExistingChart = { chartPart, chartNode, chartType, sheetName, colors, series: existingSeries, }; // Update embedded worksheet await updateEmbeddedExcelFile(existingChart, chartData); // Update inline series updateInlineSeries(existingChart, chartData); } // // Read the first series // interface ExistingChart { chartPart: OpenXmlPart; chartNode: XmlNode; chartType: ChartType; sheetName: string; colors: ChartColors; series: ExistingSeries[]; } interface ExistingSeries { sheetName: string; shapePropertiesMarkup: string; chartSpecificMarkup: string; categoriesMarkup: string; chartExtensibilityMarkup: string; } function readExistingSeries(chartNode: XmlNode, chartData: ChartData): ExistingSeries[] { const series = chartNode.childNodes?.filter(child => child.nodeName === "c:ser"); return series.map(ser => readSingleSeries(ser, chartData)); } function readSingleSeries(seriesNode: XmlNode, chartData: ChartData): ExistingSeries { const sheetName = getSheetName(seriesNode); const shapeProperties = seriesNode?.childNodes?.find(child => child.nodeName === "c:spPr"); const chartExtensibility = seriesNode?.childNodes?.find(child => child.nodeName === "c:extLst"); const formatCode = seriesNode?. childNodes?.find(child => child.nodeName === "c:cat")?. childNodes?.find(child => child.nodeName === "c:numRef")?. childNodes?.find(child => child.nodeName === "c:numCache")?. childNodes?.find(child => child.nodeName === "c:formatCode")?. childNodes?.find(child => xml.query.isTextNode(child))?. textContent; return { sheetName, shapePropertiesMarkup: xml.parser.serializeNode(shapeProperties), chartSpecificMarkup: chartSpecificMarkup(seriesNode), categoriesMarkup: categoriesMarkup(chartData, sheetName, formatCode), chartExtensibilityMarkup: xml.parser.serializeNode(chartExtensibility), }; } function getSheetName(firstSeries: XmlNode): string { if (!firstSeries) { return null; } const formulaNode = firstSeries?. childNodes?.find(child => child.nodeName === "c:tx")?. childNodes?.find(child => child.nodeName === "c:strRef")?. childNodes?.find(child => child.nodeName === "c:f"); const formula = xml.query.lastTextChild(formulaNode, false); if (!formula) { return null; } return formula.textContent?.split('!')[0]; } function categoriesMarkup(chartData: ChartData, sheetName: string, firstSeriesFormatCode: string): string { if (isScatterChartData(chartData)) { return scatterXValuesMarkup(chartData, sheetName); } return standardCategoriesMarkup(chartData, sheetName, firstSeriesFormatCode); } function standardCategoriesMarkup(chartData: StandardChartData, sheetName: string, firstSeriesFormatCode: string): string { function getCategoryName(name: unknown): any { if (name instanceof Date) { return excelDateValue(name); } return name; } const ptNodes = ` ${chartData.categories.names.map((name, index) => ` ${getCategoryName(name)} `).join("\n")} `; if (!sheetName) { // String literal if (isStringCategories(chartData.categories)) { return ` ${ptNodes} `; } // Number literal return ` ${ptNodes} `; } const formula = `${sheetName}!$A$2:$A$${chartData.categories.names.length + 1}`; // String reference if (isStringCategories(chartData.categories)) { return ` ${formula} ${ptNodes} `; } // Number reference const formatCodeValue = chartData.categories.formatCode ? formatCode(chartData.categories) : firstSeriesFormatCode ?? formatCode(chartData.categories); return ` ${formula} ${formatCodeValue} ${ptNodes} `; } function scatterXValuesMarkup(chartData: ScatterChartData, sheetName: string): string { const xValues = scatterXValues(chartData.series); const ptNodes = ` ${xValues.map((x, index) => ` ${x} `).join("\n")} `; // Number literal if (!sheetName) { return ` ${ptNodes} `; } const formula = `${sheetName}!$A$2:$A$${xValues.length + 1}`; // Number reference return ` ${formula} General ${ptNodes} `; } function chartSpecificMarkup(firstSeries: XmlNode): string { if (!firstSeries) { return ""; } const pictureOptions = firstSeries.childNodes?.find(child => child.nodeName === "c:pictureOptions"); const dLbls = firstSeries.childNodes?.find(child => child.nodeName === "c:dLbls"); const trendline = firstSeries.childNodes?.find(child => child.nodeName === "c:trendline"); const errBars = firstSeries.childNodes?.find(child => child.nodeName === "c:errBars"); const invertIfNegative = firstSeries.childNodes?.find(child => child.nodeName === "c:invertIfNegative"); const marker = firstSeries.childNodes?.find(child => child.nodeName === "c:marker"); const smooth = firstSeries.childNodes?.find(child => child.nodeName === "c:smooth"); const explosion = firstSeries.childNodes?.find(child => child.nodeName === "c:explosion"); const dPt = firstSeries.childNodes?.filter(child => child.nodeName === "c:dPt"); const firstSliceAngle = firstSeries.childNodes?.find(child => child.nodeName === "c:firstSliceAngle"); const holeSize = firstSeries.childNodes?.find(child => child.nodeName === "c:holeSize"); const serTx = firstSeries.childNodes?.find(child => child.nodeName === "c:serTx"); return ` ${xml.parser.serializeNode(pictureOptions)} ${xml.parser.serializeNode(dLbls)} ${xml.parser.serializeNode(trendline)} ${xml.parser.serializeNode(errBars)} ${xml.parser.serializeNode(invertIfNegative)} ${xml.parser.serializeNode(marker)} ${xml.parser.serializeNode(smooth)} ${xml.parser.serializeNode(explosion)} ${dPt.map(dPt => xml.parser.serializeNode(dPt)).join("\n")} ${xml.parser.serializeNode(firstSliceAngle)} ${xml.parser.serializeNode(holeSize)} ${xml.parser.serializeNode(serTx)} `; } // // Update inline series // function updateInlineSeries(existingChart: ExistingChart, chartData: ChartData) { // Remove all old series xml.modify.removeChildren(existingChart.chartNode, child => child.nodeName === "c:ser"); // Create new series const newSeries = chartData.series.map((s, index) => createSeries(existingChart, s.name, index, chartData)); for (const series of newSeries) { xml.modify.appendChild(existingChart.chartNode, series); } } function createSeries(existingChart: ExistingChart, seriesName: string, seriesIndex: number, chartData: ChartData): XmlNode { const firstSeries = existingChart.series[0]; const isNewSeries = !existingChart.series[seriesIndex]; const existingSeries = existingChart.series[seriesIndex] ?? firstSeries; const title = titleMarkup(seriesName, seriesIndex, existingSeries?.sheetName); const values = valuesMarkup(seriesIndex, chartData, existingSeries?.sheetName); const series = parseXmlNode(` ${title} ${existingSeries?.shapePropertiesMarkup ?? ""} ${existingSeries?.chartSpecificMarkup ?? ""} ${existingSeries?.categoriesMarkup ?? ""} ${values} ${existingSeries?.chartExtensibilityMarkup ?? ""} `); const color = selectSeriesColor(seriesIndex, chartData); existingChart.colors.setSeriesColor(existingChart.chartType, series, isNewSeries, color); return series; } function titleMarkup(seriesName: string, seriesIndex: number, sheetName: string): string { if (!sheetName) { return ` ${seriesName} `; } const formula = `${sheetName}!$${excelColumnId(seriesIndex + 1)}$1`; return ` ${formula} ${seriesName} `; } function valuesMarkup(seriesIndex: number, chartData: ChartData, sheetName: string): string { if (isScatterChartData(chartData)) { return scatterValuesMarkup(seriesIndex, chartData, sheetName); } return standardValuesMarkup(seriesIndex, chartData, sheetName); } function standardValuesMarkup(seriesIndex: number, chartData: StandardChartData, sheetName: string): string { if (!sheetName) { // Number literal return ` ${chartData.categories.names.map((name, catIndex) => ` ${chartData.series[seriesIndex].values[catIndex]} `).join("\n")} `; } // Number reference const columnId = excelColumnId(seriesIndex + 1); const formula = `${sheetName}!$${columnId}$2:$${columnId}$${chartData.categories.names.length + 1}`; return ` ${formula} General ${chartData.categories.names.map((name, catIndex) => ` ${chartData.series[seriesIndex].values[catIndex]} `).join("\n")} `; } function scatterValuesMarkup(seriesIndex: number, chartData: ScatterChartData, sheetName: string): string { const xValues = scatterXValues(chartData.series); const yValues = scatterYValues(xValues, chartData.series[seriesIndex]); const ptCountNode = ` `; // Y values const yValueNodes = yValues.map((y, index) => { if (y === null || y === undefined) { return ""; } return ` ${y} `; }); // Bubble size values const bubbleSizeNodes = isBubbleChartData(chartData) ? chartData.series[seriesIndex].values.map((v, index) => { if (v.size === null || v.size === undefined) { return ""; } return ` ${v.size} `; }) : []; // Number literal if (!sheetName) { const yVal = ` ${ptCountNode} ${yValueNodes.join("\n")} `; if (!isBubbleChartData(chartData)) { return yVal; } const bubbleSize = ` ${ptCountNode} ${bubbleSizeNodes.join("\n")} `; return ` ${yVal} ${bubbleSize} `; } // Number reference const yValColumnId = excelColumnId(seriesIndex + 1); const yValFormula = `${sheetName}!$${yValColumnId}$2:$${yValColumnId}$${yValues.length + 1}`; const yVal = ` ${yValFormula} General ${ptCountNode} ${yValueNodes.join("\n")} `; if (!isBubbleChartData(chartData)) { return yVal; } const bubbleSizeColumnId = excelColumnId(seriesIndex + 2); const bubbleSizeFormula = `${sheetName}!$${bubbleSizeColumnId}$2:$${bubbleSizeColumnId}$${yValues.length + 1}`; const bubbleSize = ` ${bubbleSizeFormula} General ${ptCountNode} ${bubbleSizeNodes.join("\n")} `; return ` ${yVal} ${bubbleSize} `; } function selectSeriesColor(seriesIndex: number, chartData: ChartData): string | number { // Use manual hex color const color = chartData.series[seriesIndex].color?.trim(); if (color) { const hex = color.startsWith("#") ? color.slice(1) : color; return hex.toUpperCase(); } // Auto-select accent color return seriesIndex; } // // Update the embedded Excel workbook file // async function updateEmbeddedExcelFile(existingChart: ExistingChart, chartData: ChartData) { // Get the relation ID of the embedded Excel file const rootNode = await existingChart.chartPart.xmlRoot(); const externalDataNode = rootNode.childNodes?.find(child => child.nodeName === "c:externalData") as XmlGeneralNode; const workbookRelId = externalDataNode?.attributes["r:id"]; if (!workbookRelId) { return; } // Open the embedded Excel file const xlsxPart = await existingChart.chartPart.getPartById(workbookRelId); if (!xlsxPart) { return; } const xlsxBinary = await xlsxPart.getContentBinary(); const xlsx = await Xlsx.load(xlsxBinary); // Update the workbook const workbookPart = xlsx.mainDocument; const sharedStrings = await updateSharedStringsPart(workbookPart, chartData); const sheetPart = await updateSheetPart(workbookPart, existingChart.sheetName, sharedStrings, chartData); if (sheetPart) { await updateTablePart(sheetPart, chartData); } await workbookPart.save(); // Save the Excel file const newXlsxBinary = await xlsx.export(); await xlsxPart.save(newXlsxBinary); } async function updateSharedStringsPart(workbookPart: OpenXmlPart, chartData: ChartData): Promise> { // Get the shared strings part const sharedStringsPart = await workbookPart.getFirstPartByType(RelType.SharedStrings); if (!sharedStringsPart) { return {}; } // Get the shared strings part root const root = await sharedStringsPart.xmlRoot() as XmlGeneralNode; // Remove all existing strings root.childNodes = []; let count = 0; const sharedStrings: Record = {}; function addString(str: string) { xml.modify.appendChild(root, xml.create.generalNode("si", { childNodes: [ xml.create.generalNode("t", { attributes: { [OmlAttribute.SpacePreserve]: "preserve" }, childNodes: [xml.create.textNode(str)] }) ] })); sharedStrings[str] = count; count++; } // Default strings if (isStandardChartData(chartData)) { addString(space); } if (isScatterChartData(chartData)) { addString(xValuesTitle); } // Category strings if (isStandardChartData(chartData) && isStringCategories(chartData.categories)) { for (const name of chartData.categories.names) { addString(name); } } // Series strings for (const name of chartData.series.map(s => s.name)) { addString(name); if (isBubbleChartData(chartData)) { addString(name + " Size"); } } // Update attributes root.attributes["count"] = count.toString(); root.attributes["uniqueCount"] = count.toString(); return sharedStrings; } async function updateSheetPart(workbookPart: OpenXmlPart, sheetName: string, sharedStrings: Record, chartData: ChartData): Promise { // Get the sheet rel ID const root = await workbookPart.xmlRoot(); const sheetNode = root.childNodes?. find(child => child.nodeName === "sheets")?.childNodes?. find(child => child.nodeName === "sheet" && child.attributes["name"] == sheetName) as XmlGeneralNode; const sheetRelId = sheetNode?.attributes["r:id"]; if (!sheetRelId) { return null; } // Get the sheet part const sheetPart = await workbookPart.getPartById(sheetRelId); if (!sheetPart) { return null; } const sheetRoot = await sheetPart.xmlRoot(); let newRows: XmlNode[] = []; if (isStandardChartData(chartData)) { newRows = await updateSheetRootStandard(workbookPart, sheetRoot, chartData, sharedStrings); } else if (isScatterChartData(chartData)) { newRows = await updateSheetRootScatter(workbookPart, sheetRoot, chartData, sharedStrings); } // Replace sheet data const sheetDataNode = sheetRoot.childNodes?.find(child => child.nodeName === "sheetData"); sheetDataNode.childNodes = []; for (const row of newRows) { xml.modify.appendChild(sheetDataNode, row); } return sheetPart; } async function updateSheetRootStandard(workbookPart: OpenXmlPart, sheetRoot: XmlNode, chartData: StandardChartData, sharedStrings: Record): Promise { // Create first row const firstRow = ` ${sharedStrings[space]} ${chartData.series.map((s, index) => ` ${sharedStrings[s.name]} `).join("\n")} `; // Create other rows const categoryDataTypeAttribute = isStringCategories(chartData.categories) ? ` t="s"` : ""; const categoryStyleIdAttribute = await updateStylesPart(workbookPart, sheetRoot, chartData.categories); function getCategoryName(name: unknown): any { if (name instanceof Date) { return excelDateValue(name); } if (typeof name === "string") { return sharedStrings[name]; } return name; } const otherRows = chartData.categories.names.map((name, rowIndex) => ` ${getCategoryName(name)} ${chartData.series.map((s, columnIndex) => ` ${s.values[rowIndex]} `).join("\n")} `); return [ parseXmlNode(firstRow), ...otherRows.map(row => parseXmlNode(row)) ]; } async function updateSheetRootScatter(workbookPart: OpenXmlPart, sheetRoot: XmlNode, chartData: ScatterChartData, sharedStrings: Record): Promise { const isBubbleChart = isBubbleChartData(chartData); // Create first row const firstRowColumns = chartData.series.map((s, index) => { const baseIndex = isBubbleChart ? index * 2 : index; const seriesNameColumn = ` ${sharedStrings[s.name]} `; if (!isBubbleChart) { return seriesNameColumn; } const bubbleSizeColumn = ` ${sharedStrings[s.name + " Size"]} `; return ` ${seriesNameColumn} ${bubbleSizeColumn} `; }); const firstRow = ` ${sharedStrings[xValuesTitle]} ${firstRowColumns.join("\n")} `; const xValues = scatterXValues(chartData.series); // Create other rows const yValues = chartData.series.map(s => scatterYValues(xValues, s)); const bubbleSizes = isBubbleChart ? chartData.series.map(s => bubbleSizeValues(xValues, s)) : []; function otherRowColumns(rowIndex: number) { return chartData.series.map((s, seriesIndex) => { const baseIndex = isBubbleChart ? seriesIndex * 2 : seriesIndex; const yValueColumn = ` ${yValues[seriesIndex][rowIndex]} `; if (!isBubbleChart) { return yValueColumn; } const bubbleSizeColumn = ` ${bubbleSizes[seriesIndex][rowIndex]} `; return ` ${yValueColumn} ${bubbleSizeColumn} `; }); } const otherRows = xValues.map((x, rowIndex) => ` ${x} ${otherRowColumns(rowIndex).join("\n")} `); return [ parseXmlNode(firstRow), ...otherRows.map(row => parseXmlNode(row)) ]; } async function updateTablePart(sheetPart: OpenXmlPart, chartData: ChartData) { const tablePart = await sheetPart.getFirstPartByType(RelType.Table); if (!tablePart) { return; } // Update ref attribute const tablePartRoot = await tablePart.xmlRoot() as XmlGeneralNode; tablePartRoot.attributes["ref"] = `A1:${excelRowAndColumnId(tableRowsCount(chartData), chartData.series.length)}`; // Find old table columns const tableColumnsNode = tablePartRoot.childNodes?.find(child => child.nodeName === "tableColumns"); // Add new table columns const firstColumnName = isScatterChartData(chartData) ? xValuesTitle : space; const otherColumns = chartData.series.map((s, index) => { const baseIndex = isBubbleChartData(chartData) ? index * 2 : index; return ` ${isBubbleChartData(chartData) ? ` ` : ""} `; }); const tableColumns = ` ${otherColumns.join("\n")} `; xml.modify.insertAfter(parseXmlNode(tableColumns), tableColumnsNode); // Remove old table columns xml.modify.removeChild(tablePartRoot, tableColumnsNode); } function tableRowsCount(chartData: ChartData): number { if (isScatterChartData(chartData)) { return scatterXValues(chartData.series).length; } return chartData.categories.names.length; } async function updateStylesPart(workbookPart: OpenXmlPart, sheetRoot: XmlNode, categories: Categories): Promise { // https://github.com/OpenXmlDev/Open-Xml-PowerTools/blob/vNext/OpenXmlPowerTools/ChartUpdater.cs#L507 if (isStringCategories(categories)) { return ""; } const stylesPart = await workbookPart.getFirstPartByType(RelType.Styles); const stylesRoot = await stylesPart.xmlRoot(); // Find or create cellXfs let cellXfs = stylesRoot.childNodes?.find(child => child.nodeName === "cellXfs") as XmlGeneralNode; if (!cellXfs) { const cellStyleXfs = stylesRoot.childNodes?.find(child => child.nodeName === "cellStyleXfs"); const borders = stylesRoot.childNodes?.find(child => child.nodeName === "borders"); if (!cellStyleXfs && !borders) { throw new Error("Internal error. CellXfs, CellStyleXfs and Borders not found."); } const stylesCellXfs = xml.create.generalNode("cellXfs", { attributes: { count: "0" } }); xml.modify.insertAfter(stylesCellXfs, cellStyleXfs ?? borders); // Use the cellXfs node from the sheet part cellXfs = sheetRoot.childNodes?.find(child => child.nodeName === "cellXfs") as XmlGeneralNode; } // Add xf to cellXfs const count = parseInt(cellXfs.attributes["count"]); cellXfs.attributes["count"] = (count + 1).toString(); xml.modify.appendChild(cellXfs, parseXmlNode(` `)); return `s="${count}"`; } // // Helper functions // function seriesName(name: string, index: number): string { return name ?? `Series ${index + 1}`; } function excelColumnId(i: number): string { // From: https://github.com/OpenXmlDev/Open-Xml-PowerTools/blob/vNext/OpenXmlPowerTools/PtOpenXmlUtil.cs#L1559 const A = 65; if (i >= 0 && i <= 25) { return String.fromCharCode(A + i); } if (i >= 26 && i <= 701) { const v = i - 26; const h = Math.floor(v / 26); const l = v % 26; return String.fromCharCode(A + h) + String.fromCharCode(A + l); } // 17576 if (i >= 702 && i <= 18277) { const v = i - 702; const h = Math.floor(v / 676); const r = v % 676; const m = Math.floor(r / 26); const l = r % 26; return String.fromCharCode(A + h) + String.fromCharCode(A + m) + String.fromCharCode(A + l); } throw new Error(`Column reference out of range: ${i}`); } function excelRowAndColumnId(row: number, col: number): string { return excelColumnId(col) + (row + 1).toString(); } function excelDateValue(date: Date): number { const millisPerDay = 86400000; const excelEpoch = new Date("1899-12-30"); return (date.getTime() - excelEpoch.getTime()) / millisPerDay; } function parseXmlNode(xmlString: string): XmlNode { const xmlNode = xml.parser.parse(xmlString); xml.modify.removeEmptyTextNodes(xmlNode); return xmlNode; }