import type { CellFormat, MergedCellRange, SheetChart, SheetChartType, Workbook } from "../types"; import { evaluateCell, expandRange } from "../utils/spreadsheet"; import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers"; export interface ImportedExcellWorkbook { title: string; sheets: Array<{ id: string; name: string; cells: Record; mergedCells?: MergedCellRange[]; hyperlinks?: Record; cellFormats?: Record; charts?: SheetChart[]; }>; } type XmlRecord = Record; type ZipObject = { async: (type: "string") => Promise; }; type ZipInstance = { files?: Record; file: { (path: string): ZipObject | null; (path: string, data: string): ZipInstance; }; generateAsync: (options: { type: "blob"; mimeType: string }) => Promise; }; type ZipConstructor = { new (): ZipInstance; loadAsync: (data: ArrayBuffer) => Promise; }; type XmlParserConstructor = new (options: Record) => { parse: (xml: string) => unknown; }; type FastXmlParserModule = { XMLParser: XmlParserConstructor; }; interface WorkbookSheetRef { id: string; name: string; relationshipId: string; } interface ParsedSheet { id: string; name: string; cells: Record; mergedCells?: MergedCellRange[]; hyperlinks?: Record; cellFormats?: Record; charts?: SheetChart[]; } interface WorksheetCellRecord { address: string; cell: XmlRecord; } interface HyperlinkEntry { address: string; target: string; external: boolean; location?: string; } interface SharedFormulaDefinition { formula: string; baseAddress: string; } type SharedFormulaMap = Record; export interface CellStyleRegistry { formats: CellFormat[]; idsByKey: Record; } interface ChartPartRef { sheetIndex: number; drawingIndex: number; chartIndex: number; relationshipId: string; chart: SheetChart; cells: Record; sheetName: string; } const xmlParserOptions = { ignoreAttributes: false, attributeNamePrefix: "", textNodeName: "#text", parseTagValue: false, parseAttributeValue: false, trimValues: false }; const hyperlinkRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; const drawingRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"; const chartRelationshipType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"; const customNumberFormatStartId = 164; const defaultXlsxThemeColors = [ "000000", "FFFFFF", "1F497D", "EEECE1", "4F81BD", "C0504D", "9BBB59", "8064A2", "4BACC6", "F79646", "0000FF", "800080" ]; const indexedXlsxColors: Record = { 0: "000000", 1: "FFFFFF", 2: "FF0000", 3: "00FF00", 4: "0000FF", 5: "FFFF00", 6: "FF00FF", 7: "00FFFF", 8: "000000", 9: "FFFFFF", 10: "FF0000", 11: "00FF00", 12: "0000FF", 13: "FFFF00", 14: "FF00FF", 15: "00FFFF", 22: "C0C0C0", 23: "808080", 64: "" }; const builtinNumberFormats: Record = { 1: "0", 2: "0.00", 3: "#,##0", 4: "#,##0.00", 9: "0%", 10: "0.00%", 11: "0.00E+00", 12: "# ?/?", 13: "# ??/??", 14: "m/d/yy", 15: "d-mmm-yy", 16: "d-mmm", 17: "mmm-yy", 18: "h:mm AM/PM", 19: "h:mm:ss AM/PM", 20: "h:mm", 21: "h:mm:ss", 22: "m/d/yy h:mm", 37: "#,##0 ;(#,##0)", 38: "#,##0 ;[Red](#,##0)", 39: "#,##0.00;(#,##0.00)", 40: "#,##0.00;[Red](#,##0.00)", 45: "mm:ss", 46: "[h]:mm:ss", 47: "mmss.0", 48: "##0.0E+0", 49: "@" }; export async function importXlsxFile(file: File): Promise { if (!file.name.toLowerCase().endsWith(".xlsx")) { throw new Error("QExcell импортирует только файлы Microsoft Excel .xlsx."); } const [JSZip, XMLParser] = await Promise.all([loadJsZip(), loadXmlParser()]); const arrayBuffer = await readOfficeFileAsArrayBuffer(file); const zip = await JSZip.loadAsync(arrayBuffer); const parser = new XMLParser(xmlParserOptions); const workbookXml = parser.parse(await readZipText(zip, "xl/workbook.xml")); const relationshipsXml = parser.parse(await readZipText(zip, "xl/_rels/workbook.xml.rels")); const sharedStrings = await readSharedStrings(zip, parser); const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser)); const sheetRefs = workbookSheetRefs(workbookXml); const relationshipTargets = workbookRelationshipTargets(relationshipsXml); const sheets = await Promise.all( sheetRefs.map(async (sheet, index) => { const target = relationshipTargets[sheet.relationshipId]; if (!target) { return { id: sheet.id, name: sheet.name, cells: {}, mergedCells: [], hyperlinks: {}, cellFormats: {}, charts: [] }; } const sheetPath = resolveWorkbookTarget(target); const sheetXml = parser.parse(await readZipText(zip, sheetPath)); const sheetRelationshipsXml = await readOptionalSheetRelationships(zip, parser, sheetPath); const parsedSheet = parsedWorksheetToSheet(sheetXml, sheet, index, sharedStrings, sheetRelationshipsXml, styleTable); const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name); return { ...parsedSheet, charts }; }) ); return { title: officeTitleFromFileName(file.name, "Книга QExcell.xlsx"), sheets }; } export async function exportXlsxBlob(workbook: Workbook): Promise { const JSZip = await loadJsZip(); const zip = new JSZip(); const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }]; const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats)); const chartParts = chartPartRefsForSheets(sheets); zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts)); zip.file("_rels/.rels", buildRootRelationshipsXml()); zip.file("docProps/app.xml", buildAppPropertiesXml()); zip.file("docProps/core.xml", buildCorePropertiesXml()); zip.file("xl/workbook.xml", buildWorkbookXml(sheets.map((sheet, index) => sheet.name.trim() || `Лист ${index + 1}`))); zip.file("xl/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length)); zip.file("xl/styles.xml", buildStylesXml(styleRegistry)); sheets.forEach((sheet, index) => { const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index); const drawingRelationshipId = sheetChartParts.length > 0 ? `rId${sortedExternalHyperlinkEntries(sheet.hyperlinks).length + 1}` : ""; zip.file( `xl/worksheets/sheet${index + 1}.xml`, buildWorksheetXml(sheet.cells, sheet.mergedCells, sheet.hyperlinks, sheet.cellFormats, styleRegistry, drawingRelationshipId) ); const relationshipsXml = buildWorksheetRelationshipsXml( sheet.hyperlinks, sheetChartParts.length > 0 ? `../drawings/drawing${sheetChartParts[0].drawingIndex}.xml` : "" ); if (relationshipsXml) { zip.file(`xl/worksheets/_rels/sheet${index + 1}.xml.rels`, relationshipsXml); } if (sheetChartParts.length > 0) { const drawingIndex = sheetChartParts[0].drawingIndex; zip.file(`xl/drawings/drawing${drawingIndex}.xml`, buildDrawingXml(sheetChartParts)); zip.file(`xl/drawings/_rels/drawing${drawingIndex}.xml.rels`, buildDrawingRelationshipsXml(sheetChartParts)); sheetChartParts.forEach((part) => { zip.file(`xl/charts/chart${part.chartIndex}.xml`, buildChartXml(part.chart, part.sheetName, part.cells)); }); } }); return zip.generateAsync({ type: "blob", mimeType: XLSX_MIME_TYPE }); } export function parsedWorksheetToSheet( worksheetXml: unknown, sheet: WorkbookSheetRef, index: number, sharedStrings: string[], relationshipsXml?: unknown, styleTable: Array = [] ): ParsedSheet { return { id: sheet.id || `sheet-${index + 1}`, name: sheet.name || `Лист ${index + 1}`, cells: worksheetXmlToCells(worksheetXml, sharedStrings), mergedCells: worksheetXmlToMergedCells(worksheetXml), hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml), cellFormats: worksheetXmlToCellFormats(worksheetXml, styleTable), charts: [] }; } export function worksheetXmlToCells(worksheetXml: unknown, sharedStrings: string[]) { const cells: Record = {}; const sharedFormulas: SharedFormulaMap = {}; const parsedCells = worksheetCellRecords(worksheetXml); parsedCells.forEach(({ address, cell }) => { const formulaRecord = asRecord(cell.f); const sharedFormulaId = sharedFormulaKey(formulaRecord); const formulaText = formulaNodeText(cell.f); if (sharedFormulaId && formulaText) { sharedFormulas[sharedFormulaId] = { formula: formulaText, baseAddress: address }; } }); parsedCells.forEach(({ address, cell }) => { const text = xlsxCellToText(cell, sharedStrings, sharedFormulas, address); if (text !== "") { cells[address] = text; } }); return cells; } export function worksheetXmlToCellFormats(worksheetXml: unknown, styleTable: Array = []) { return worksheetCellRecords(worksheetXml).reduce>((formats, { address, cell }) => { const styleIndex = numberAttribute(cell.s); const format = normalizedCellFormat(styleTable[styleIndex]); if (format) { formats[address] = format; } return formats; }, {}); } function worksheetCellRecords(worksheetXml: unknown): WorksheetCellRecord[] { const worksheet = childRecord(worksheetXml, "worksheet"); const sheetData = childRecord(worksheet, "sheetData"); const rows = toArray(sheetData.row); return rows.flatMap((row, rowIndex) => { const rowRecord = asRecord(row); const rowNumber = Number(rowRecord.r) || rowIndex + 1; return toArray(rowRecord.c).map((cell, cellIndex) => { const cellRecord = asRecord(cell); return { address: String(cellRecord.r || cellAddress(cellIndex + 1, rowNumber)).toUpperCase(), cell: cellRecord }; }); }); } export function worksheetXmlToMergedCells(worksheetXml: unknown): MergedCellRange[] { const worksheet = childRecord(worksheetXml, "worksheet"); const mergeCells = childRecord(worksheet, "mergeCells"); return toArray(mergeCells.mergeCell) .map((mergeCell) => normalizeMergeRef(String(asRecord(mergeCell).ref || ""))) .filter((range): range is MergedCellRange => Boolean(range)); } export function worksheetXmlToHyperlinks(worksheetXml: unknown, relationshipsXml?: unknown): Record { const worksheet = childRecord(worksheetXml, "worksheet"); const hyperlinks = childRecord(worksheet, "hyperlinks"); const relationshipTargets = worksheetRelationshipTargets(relationshipsXml); return toArray(hyperlinks.hyperlink).reduce>((links, hyperlink) => { const record = asRecord(hyperlink); const ref = normalizeCellAddress(String(record.ref || "")); const relationshipId = String(record["r:id"] || ""); const location = String(record.location || ""); const target = normalizedHyperlinkTarget(relationshipTargets[relationshipId] || "") || normalizedInternalHyperlinkTarget(location, true); if (ref && target) { links[ref] = target; } return links; }, {}); } async function worksheetXmlToCharts( zip: ZipInstance, parser: InstanceType, worksheetXml: unknown, relationshipsXml: unknown, sheetPath: string, sheetName: string ): Promise { const worksheet = childRecord(worksheetXml, "worksheet"); const drawingIds = toArray(worksheet.drawing) .map((drawing) => String(asRecord(drawing)["r:id"] || "")) .filter(Boolean); if (drawingIds.length === 0) { return []; } const drawingTargets = relationshipTargetsByType(relationshipsXml, drawingRelationshipType, sheetPath); const charts = await Promise.all( drawingIds.flatMap((drawingId) => { const drawingPath = drawingTargets[drawingId]; return drawingPath ? [chartsFromDrawing(zip, parser, drawingPath, sheetName)] : []; }) ); return charts.flat().filter((chart, index, charts) => charts.findIndex((candidate) => candidate.id === chart.id) === index); } async function chartsFromDrawing( zip: ZipInstance, parser: InstanceType, drawingPath: string, sheetName: string ): Promise { const drawingXmlText = await readOptionalZipText(zip, drawingPath); if (!drawingXmlText) { return []; } const drawingXml = parser.parse(drawingXmlText); const drawingRelationshipsXml = await readOptionalRelationships(zip, parser, drawingPath); const chartTargets = relationshipTargetsByType(drawingRelationshipsXml, chartRelationshipType, drawingPath); const chartRefs = recordsByLocalName(drawingXml, "chart") .map((chart) => String(chart["r:id"] || "")) .filter(Boolean); const charts = await Promise.all( chartRefs.flatMap((relationshipId, index) => { const chartPath = chartTargets[relationshipId]; return chartPath ? [chartFromPart(zip, parser, chartPath, sheetName, index)] : []; }) ); return charts.filter((chart): chart is SheetChart => Boolean(chart)); } async function chartFromPart( zip: ZipInstance, parser: InstanceType, chartPath: string, sheetName: string, index: number ): Promise { const chartXmlText = await readOptionalZipText(zip, chartPath); if (!chartXmlText) { return null; } return chartXmlToSheetChart(parser.parse(chartXmlText), sheetName, index); } export function chartXmlToSheetChart(chartXml: unknown, sheetName = "", index = 0): SheetChart | null { const type = chartTypeFromXml(chartXml); if (!type) { return null; } const chartRecord = firstRecordByLocalName(chartXml, type === "line" ? "lineChart" : type === "pie" ? "pieChart" : "barChart"); const series = firstRecordByLocalName(chartRecord, "ser"); const labelRange = chartRangeFromRecord(firstRecordByLocalName(series, "cat"), sheetName); const valueRange = chartRangeFromRecord(firstRecordByLocalName(series, "val"), sheetName); if (!labelRange || !valueRange) { return null; } return { id: `xlsx-chart-${index + 1}`, type, title: chartTitleText(chartXml) || chartTypeLabel(type), labelRange, valueRange }; } function chartTypeFromXml(chartXml: unknown): SheetChartType | "" { if (recordsByLocalName(chartXml, "barChart").length > 0) { return "bar"; } if (recordsByLocalName(chartXml, "lineChart").length > 0) { return "line"; } if (recordsByLocalName(chartXml, "pieChart").length > 0) { return "pie"; } return ""; } function chartTitleText(chartXml: unknown) { const title = firstRecordByLocalName(chartXml, "title"); const text = textValuesByLocalName(title, "t").join(" "); return normalizeWhitespace(text); } function chartRangeFromRecord(record: XmlRecord, sheetName: string) { const formula = textValuesByLocalName(record, "f") .map((value) => chartRangeFromFormula(value, sheetName)) .find(Boolean); return formula ?? ""; } function chartRangeFromFormula(formula: string, sheetName: string) { const raw = formula.trim(); const withoutSheet = raw.includes("!") ? raw.slice(raw.lastIndexOf("!") + 1) : raw; const normalizedSheet = raw.includes("!") ? raw.slice(0, raw.lastIndexOf("!")).replace(/^'|'$/g, "").replace(/''/g, "'") : ""; if (sheetName && normalizedSheet && normalizedSheet !== sheetName) { return ""; } return normalizeChartRange(withoutSheet.replace(/\$/g, "")); } export function xlsxCellToText(cell: XmlRecord, sharedStrings: string[], sharedFormulas: SharedFormulaMap = {}, address = "") { const formula = formulaNodeText(cell.f); if (formula) { return formula.startsWith("=") ? formula : `=${formula}`; } const sharedFormulaId = sharedFormulaKey(asRecord(cell.f)); if (sharedFormulaId && sharedFormulas[sharedFormulaId]) { const sharedFormula = sharedFormulas[sharedFormulaId]; const formulaText = typeof sharedFormula === "string" ? sharedFormula : translateSharedFormula(sharedFormula.formula, sharedFormula.baseAddress, normalizeCellAddress(address || String(cell.r || ""))); return formulaText.startsWith("=") ? formulaText : `=${formulaText}`; } const type = typeof cell.t === "string" ? cell.t : ""; if (type === "s") { const index = Number(xmlNodeText(cell.v)); return Number.isInteger(index) ? sharedStrings[index] ?? "" : ""; } if (type === "inlineStr") { return xmlNodeText(cell.is); } if (type === "b") { return xmlNodeText(cell.v) === "1" ? "true" : "false"; } return xmlNodeText(cell.v); } export function buildWorksheetXml( cells: Record, mergedCells: MergedCellRange[] = [], hyperlinks: Record = {}, cellFormats: Record = {}, styleRegistry = createCellStyleRegistry([cellFormats]), drawingRelationshipId = "" ) { const rows = sortedWorksheetEntries(cells, cellFormats).reduce>>((grouped, entry) => { const rowNumber = cellRowNumber(entry[0]); if (!grouped[rowNumber]) { grouped[rowNumber] = []; } grouped[rowNumber].push(entry); return grouped; }, {}); const rowXml = Object.entries(rows) .map(([rowNumber, entries]) => { const cellXml = entries.map(([address, value]) => buildCellXml(address, value, cellStyleId(cellFormats[address], styleRegistry))).join(""); return `${cellXml}`; }) .join(""); const mergeCellsXml = buildMergeCellsXml(mergedCells); const hyperlinksXml = buildHyperlinksXml(hyperlinks); const drawingXml = drawingRelationshipId ? `` : ""; return xmlDeclaration( `${rowXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}` ); } export function buildCellXml(address: string, value: string, styleId = 0) { const cellAttributes = `r="${escapeXmlAttribute(address.toUpperCase())}"${styleId > 0 ? ` s="${styleId}"` : ""}`; if (value === "") { return ``; } if (value.startsWith("=") && value.trim().length > 1) { return `${escapeXmlText(value.slice(1))}`; } if (isPlainNumber(value)) { return `${value}`; } return `${textElement(value)}`; } export function buildMergeCellsXml(mergedCells: MergedCellRange[] = []) { const refs = mergedCells .map((range) => normalizeMergeRange(range)) .filter((range): range is MergedCellRange => Boolean(range)) .map((range) => `${range.start}:${range.end}`) .filter((ref, index, refs) => refs.indexOf(ref) === index); if (refs.length === 0) { return ""; } const mergeCellXml = refs.map((ref) => ``).join(""); return `${mergeCellXml}`; } export function buildHyperlinksXml(hyperlinks: Record = {}) { const refs = sortedHyperlinkEntries(hyperlinks); if (refs.length === 0) { return ""; } let relationshipIndex = 0; const hyperlinkXml = refs .map((entry) => { if (!entry.external) { return ``; } relationshipIndex += 1; return ``; }) .join(""); return `${hyperlinkXml}`; } export function buildWorksheetRelationshipsXml(hyperlinks: Record = {}, drawingTarget = "") { const refs = sortedExternalHyperlinkEntries(hyperlinks); if (refs.length === 0 && !drawingTarget) { return ""; } const relationships = refs .map( (entry, index) => `` ) .join(""); const drawingRelationship = drawingTarget ? `` : ""; return xmlDeclaration(`${relationships}${drawingRelationship}`); } export function workbookSheetRefs(workbookXml: unknown): WorkbookSheetRef[] { const workbook = childRecord(workbookXml, "workbook"); const sheets = childRecord(workbook, "sheets"); return toArray(sheets.sheet).map((sheet, index) => { const record = asRecord(sheet); return { id: `sheet-${String(record.sheetId || index + 1)}`, name: String(record.name || `Лист ${index + 1}`), relationshipId: String(record["r:id"] || "") }; }); } export function workbookRelationshipTargets(relationshipsXml: unknown) { const relationships = childRecord(relationshipsXml, "Relationships"); const targets: Record = {}; toArray(relationships.Relationship).forEach((relationship) => { const record = asRecord(relationship); if (typeof record.Id === "string" && typeof record.Target === "string") { targets[record.Id] = record.Target; } }); return targets; } export function worksheetRelationshipTargets(relationshipsXml: unknown) { const relationships = childRecord(relationshipsXml, "Relationships"); const targets: Record = {}; toArray(relationships.Relationship).forEach((relationship) => { const record = asRecord(relationship); const id = String(record.Id || ""); const type = String(record.Type || ""); const target = normalizedHyperlinkTarget(String(record.Target || "")); if (id && type === hyperlinkRelationshipType && target) { targets[id] = target; } }); return targets; } function buildContentTypesXml(sheetCount: number, chartParts: ChartPartRef[] = []) { const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => { return ``; }).join(""); const drawingOverrides = uniqueNumbers(chartParts.map((part) => part.drawingIndex)) .map((drawingIndex) => ``) .join(""); const chartOverrides = chartParts .map((part) => ``) .join(""); return xmlDeclaration( `${sheetOverrides}${drawingOverrides}${chartOverrides}` ); } function buildRootRelationshipsXml() { return xmlDeclaration( `` ); } function buildWorkbookXml(sheetNames: string[]) { const sheets = sheetNames .map((name, index) => { return ``; }) .join(""); return xmlDeclaration( `${sheets}` ); } function buildWorkbookRelationshipsXml(sheetCount: number) { const sheetRelationships = Array.from({ length: sheetCount }, (_, index) => { return ``; }).join(""); const stylesRelationshipId = sheetCount + 1; return xmlDeclaration( `${sheetRelationships}` ); } function chartPartRefsForSheets(sheets: Workbook["sheets"]): ChartPartRef[] { const refs: ChartPartRef[] = []; let drawingIndex = 1; let chartIndex = 1; sheets.forEach((sheet, sheetIndex) => { const charts = normalizedSheetCharts(sheet.charts); if (charts.length === 0) { return; } const currentDrawingIndex = drawingIndex; drawingIndex += 1; charts.forEach((chart, chartOffset) => { refs.push({ sheetIndex, drawingIndex: currentDrawingIndex, chartIndex, relationshipId: `rId${chartOffset + 1}`, chart, cells: sheet.cells, sheetName: sheet.name.trim() || `Лист ${sheetIndex + 1}` }); chartIndex += 1; }); }); return refs; } function normalizedSheetCharts(charts: SheetChart[] = []) { return charts .map(normalizedChart) .filter((chart): chart is SheetChart => Boolean(chart)) .filter((chart, index, charts) => charts.findIndex((candidate) => candidate.id === chart.id) === index); } function normalizedChart(chart: SheetChart | undefined): SheetChart | null { if (!chart) { return null; } const labelRange = normalizeChartRange(chart.labelRange); const valueRange = normalizeChartRange(chart.valueRange); if (!labelRange || !valueRange) { return null; } const type: SheetChartType = ["bar", "line", "pie"].includes(chart.type) ? chart.type : "bar"; const title = normalizeWhitespace(String(chart.title || chartTypeLabel(type))); return { id: chart.id.trim() || `chart-${Date.now()}`, type, title, labelRange, valueRange }; } function buildDrawingXml(chartParts: ChartPartRef[]) { const anchors = chartParts .map((part, index) => { const topRow = 1 + index * 15; const bottomRow = topRow + 12; return `60${topRow}0130${bottomRow}0`; }) .join(""); return xmlDeclaration( `${anchors}` ); } function buildDrawingRelationshipsXml(chartParts: ChartPartRef[]) { const relationships = chartParts .map( (part) => `` ) .join(""); return xmlDeclaration(`${relationships}`); } export function buildChartXml(chart: SheetChart, sheetName: string, cells: Record = {}) { const normalized = normalizedChart(chart); if (!normalized) { throw new Error("Не удалось создать диаграмму XLSX: указаны некорректные диапазоны."); } const points = chartDataPoints(normalized, cells); const seriesXml = buildChartSeriesXml(normalized, sheetName, points); const plotXml = normalized.type === "line" ? `${seriesXml}${buildChartAxesXml()}` : normalized.type === "pie" ? `${seriesXml}` : `${seriesXml}${buildChartAxesXml()}`; return xmlDeclaration( `${escapeXmlText(normalized.title)}${plotXml}` ); } function buildChartSeriesXml(chart: SheetChart, sheetName: string, points: Array<{ label: string; value: number }>) { const labelFormula = chartRangeFormula(sheetName, chart.labelRange); const valueFormula = chartRangeFormula(sheetName, chart.valueRange); const labelCache = points .map((point, index) => `${escapeXmlText(point.label)}`) .join(""); const valueCache = points .map((point, index) => `${point.value}`) .join(""); return `${escapeXmlText(chart.title)}${escapeXmlText(labelFormula)}${labelCache}${escapeXmlText(valueFormula)}General${valueCache}`; } function buildChartAxesXml() { return ``; } export function chartDataPoints(chart: SheetChart, cells: Record) { const labelCells = expandRange(chart.labelRange); const valueCells = expandRange(chart.valueRange); const length = Math.min(labelCells.length, valueCells.length); return Array.from({ length }, (_, index) => { const label = cells[labelCells[index]] || labelCells[index]; const evaluated = evaluateCell(valueCells[index], cells); const value = Number(evaluated); return { label, value: Number.isFinite(value) ? value : 0 }; }); } function chartRangeFormula(sheetName: string, range: string) { return `${quotedSheetName(sheetName)}!${absoluteChartRange(range)}`; } function quotedSheetName(sheetName: string) { return `'${sheetName.replace(/'/g, "''")}'`; } function absoluteChartRange(range: string) { const normalized = normalizeChartRange(range); const [start, end = start] = normalized.split(":"); return `${absoluteCellAddress(start)}:${absoluteCellAddress(end)}`; } function absoluteCellAddress(address: string) { const column = address.match(/^[A-Z]+/i)?.[0]?.toUpperCase() ?? "A"; const row = address.match(/[1-9][0-9]*$/)?.[0] ?? "1"; return `$${column}$${row}`; } function chartTypeLabel(type: SheetChartType) { if (type === "line") { return "Линейная диаграмма"; } if (type === "pie") { return "Круговая диаграмма"; } return "Столбчатая диаграмма"; } export function createCellStyleRegistry(cellFormatsBySheet: Array | undefined>): CellStyleRegistry { const formats: CellFormat[] = []; const idsByKey: Record = {}; cellFormatsBySheet.forEach((cellFormats) => { Object.values(cellFormats ?? {}).forEach((format) => { const key = cellFormatKey(format); if (!key || idsByKey[key]) { return; } idsByKey[key] = formats.length + 1; const normalized = normalizedCellFormat(format); if (normalized) { formats.push(normalized); } }); }); return { formats, idsByKey }; } export function xlsxStylesXmlToCellFormatTable(stylesXml?: unknown): Array { const styleSheet = childRecord(stylesXml, "styleSheet"); const numberFormats = xlsxNumberFormats(styleSheet); const fonts = toArray(childRecord(styleSheet, "fonts").font).map((font, index) => fontXmlToCellFormat(font, index)); const fills = toArray(childRecord(styleSheet, "fills").fill).map(fillXmlToCellFormat); const borders = toArray(childRecord(styleSheet, "borders").border).map(borderXmlToCellFormat); return toArray(childRecord(styleSheet, "cellXfs").xf).map((xf) => { const record = asRecord(xf); const fontFormat = fonts[numberAttribute(record.fontId)]; const fillFormat = fills[numberAttribute(record.fillId)]; const borderFormat = borders[numberAttribute(record.borderId)]; const numberFormat = normalizedNumberFormat(numberFormats[numberAttribute(record.numFmtId)]); const alignment = childRecord(record, "alignment"); const horizontalAlign = normalizedHorizontalAlign(String(alignment.horizontal || "")); const verticalAlign = normalizedVerticalAlign(String(alignment.vertical || "")); const wrapText = booleanAttribute(alignment.wrapText); return normalizedCellFormat({ ...fontFormat, ...fillFormat, ...borderFormat, ...(horizontalAlign ? { horizontalAlign } : {}), ...(verticalAlign ? { verticalAlign } : {}), ...(wrapText ? { wrapText: true } : {}), ...(numberFormat ? { numberFormat } : {}) }) ?? undefined; }); } function buildStylesXml(styleRegistry = createCellStyleRegistry([])) { const fonts = [baseFontXml()]; const fills = ['', '']; const borders = [baseBorderXml()]; const fontIdsByKey: Record = {}; const fillIdsByColor: Record = {}; const borderIdsByColor: Record = {}; const numberFormatIdsByCode: Record = {}; const customNumberFormats: Array<[number, string]> = []; const cellXfs = styleRegistry.formats.map((format) => { const fontKey = fontStyleKey(format); let fontId = 0; if (fontKey) { fontId = fontIdsByKey[fontKey] ?? fonts.length; if (!fontIdsByKey[fontKey]) { fontIdsByKey[fontKey] = fontId; fonts.push(formatFontXml(format)); } } let fillId = 0; if (format.fillColor) { fillId = fillIdsByColor[format.fillColor] ?? fills.length; if (!fillIdsByColor[format.fillColor]) { fillIdsByColor[format.fillColor] = fillId; fills.push(formatFillXml(format.fillColor)); } } let borderId = 0; if (format.border) { const borderKey = format.borderColor || "auto"; borderId = borderIdsByColor[borderKey] ?? borders.length; if (!borderIdsByColor[borderKey]) { borderIdsByColor[borderKey] = borderId; borders.push(formatBorderXml(format.borderColor)); } } let numFmtId = 0; if (format.numberFormat) { numFmtId = numberFormatIdsByCode[format.numberFormat] ?? customNumberFormatStartId + customNumberFormats.length; if (!numberFormatIdsByCode[format.numberFormat]) { numberFormatIdsByCode[format.numberFormat] = numFmtId; customNumberFormats.push([numFmtId, format.numberFormat]); } } const applyFont = fontId > 0 ? ' applyFont="1"' : ""; const applyFill = fillId > 0 ? ' applyFill="1"' : ""; const applyBorder = borderId > 0 ? ' applyBorder="1"' : ""; const applyAlignment = format.horizontalAlign || format.verticalAlign || format.wrapText ? ' applyAlignment="1"' : ""; const applyNumberFormat = numFmtId > 0 ? ' applyNumberFormat="1"' : ""; const alignmentAttributes = [ format.horizontalAlign ? `horizontal="${format.horizontalAlign}"` : "", format.verticalAlign ? `vertical="${xlsxVerticalAlignValue(format.verticalAlign)}"` : "", format.wrapText ? 'wrapText="1"' : "" ] .filter(Boolean) .join(" "); const alignmentXml = alignmentAttributes ? `` : ""; return `${alignmentXml}`; }); const numFmtsXml = customNumberFormats.length > 0 ? `${customNumberFormats .map(([id, code]) => ``) .join("")}` : ""; return xmlDeclaration( `${numFmtsXml}${fonts.join("")}${fills.join("")}${borders.join("")}${cellXfs.join("")}` ); } function buildAppPropertiesXml() { return xmlDeclaration( `QOffice` ); } function buildCorePropertiesXml() { const now = new Date().toISOString(); return xmlDeclaration( `QOfficeQOffice${now}${now}` ); } function cellStyleId(format: CellFormat | undefined, styleRegistry: CellStyleRegistry) { const key = cellFormatKey(format); return key ? (styleRegistry.idsByKey[key] ?? 0) : 0; } function cellFormatKey(format: CellFormat | undefined) { const normalized = normalizedCellFormat(format); if (!normalized) { return ""; } return `${normalized.bold ? "1" : "0"}|${normalized.italics ? "1" : "0"}|${normalized.underline ? "1" : "0"}|${normalized.strike ? "1" : "0"}|${normalized.fontSize ?? ""}|${normalized.fontFamily ?? ""}|${normalized.textColor ?? ""}|${normalized.fillColor ?? ""}|${normalized.horizontalAlign ?? ""}|${normalized.verticalAlign ?? ""}|${normalized.wrapText ? "1" : "0"}|${normalized.border ? "1" : "0"}|${normalized.borderColor ?? ""}|${normalized.numberFormat ?? ""}`; } function fontStyleKey(format: CellFormat) { return format.bold || format.italics || format.underline || format.strike || format.fontSize || format.fontFamily || format.textColor ? `${format.bold ? "1" : "0"}|${format.italics ? "1" : "0"}|${format.underline ? "1" : "0"}|${format.strike ? "1" : "0"}|${format.fontSize ?? ""}|${format.fontFamily ?? ""}|${format.textColor ?? ""}` : ""; } function normalizedCellFormat(format: CellFormat | undefined): CellFormat | null { const textColor = normalizeFillColor(format?.textColor); const fillColor = normalizeFillColor(format?.fillColor); const borderColor = normalizeFillColor(format?.borderColor); const horizontalAlign = normalizedHorizontalAlign(format?.horizontalAlign); const verticalAlign = normalizedVerticalAlign(format?.verticalAlign); const numberFormat = normalizedNumberFormat(format?.numberFormat); const fontSize = normalizedFontSize(format?.fontSize); const fontFamily = normalizedFontFamily(format?.fontFamily); const normalized: CellFormat = { ...(format?.bold ? { bold: true } : {}), ...(format?.italics ? { italics: true } : {}), ...(format?.underline ? { underline: true } : {}), ...(format?.strike ? { strike: true } : {}), ...(fontSize ? { fontSize } : {}), ...(fontFamily ? { fontFamily } : {}), ...(textColor ? { textColor } : {}), ...(fillColor ? { fillColor } : {}), ...(horizontalAlign ? { horizontalAlign } : {}), ...(verticalAlign ? { verticalAlign } : {}), ...(format?.wrapText ? { wrapText: true } : {}), ...(format?.border || borderColor ? { border: true } : {}), ...(borderColor ? { borderColor } : {}), ...(numberFormat ? { numberFormat } : {}) }; return Object.keys(normalized).length > 0 ? normalized : null; } function fontXmlToCellFormat(font: unknown, index = 0): CellFormat { const record = asRecord(font); const textColor = colorRecordToHex(childRecord(record, "color"), { ignoreThemeAndIndexed: index === 0 }); const fontSize = normalizedFontSize(numberAttribute(childRecord(record, "sz").val)); const fontFamily = normalizedFontFamily(String(childRecord(record, "name").val || "")); return ( normalizedCellFormat({ bold: hasXmlElement(record, "b"), italics: hasXmlElement(record, "i"), underline: hasXmlElement(record, "u"), strike: hasXmlElement(record, "strike"), ...(fontSize ? { fontSize } : {}), ...(fontFamily ? { fontFamily } : {}), ...(textColor ? { textColor } : {}) }) ?? {} ); } function fillXmlToCellFormat(fill: unknown): CellFormat { const patternFill = childRecord(fill, "patternFill"); const fillColor = colorRecordToHex(childRecord(patternFill, "fgColor")); return fillColor ? { fillColor } : {}; } function borderXmlToCellFormat(border: unknown): CellFormat { const record = asRecord(border); const borderedSides = ["left", "right", "top", "bottom"].filter((side) => { const style = String(childRecord(record, side).style || childRecord(record, side)["@_style"] || "").trim().toLowerCase(); return style && style !== "none"; }); const borderColor = borderedSides .map((side) => colorRecordToHex(childRecord(childRecord(record, side), "color"))) .find(Boolean); return normalizedCellFormat({ border: borderedSides.length > 0, ...(borderColor ? { borderColor } : {}) }) ?? {}; } function baseFontXml() { return ''; } function formatFontXml(format: CellFormat) { const flags = `${format.bold ? "" : ""}${format.italics ? "" : ""}${format.underline ? "" : ""}${format.strike ? "" : ""}`; const color = format.textColor ? `` : ""; return `${flags}${color}`; } function formatFillXml(fillColor: string) { return ``; } function baseBorderXml() { return ""; } function formatBorderXml(borderColor = "") { const colorXml = borderColor ? `` : ''; return `${colorXml}${colorXml}${colorXml}${colorXml}`; } function normalizeFillColor(value?: string) { const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? ""; if (/^[0-9A-F]{8}$/.test(raw)) { return raw.slice(2); } return /^[0-9A-F]{6}$/.test(raw) ? raw : ""; } function colorRecordToHex(record: XmlRecord, options: { ignoreThemeAndIndexed?: boolean } = {}) { const rgbColor = normalizeFillColor(String(record.rgb ?? "")); if (rgbColor) { return rgbColor; } if (options.ignoreThemeAndIndexed) { return ""; } if (record.theme !== undefined) { const themeColor = themeColorToHex(numberAttribute(record.theme), Number.parseFloat(String(record.tint ?? ""))); if (themeColor) { return themeColor; } } return record.indexed !== undefined ? indexedXlsxColors[numberAttribute(record.indexed)] ?? "" : ""; } function themeColorToHex(themeIndex: number, tint = 0) { const baseColor = defaultXlsxThemeColors[themeIndex]; if (!baseColor) { return ""; } return Number.isFinite(tint) && tint !== 0 ? applyXlsxTint(baseColor, tint) : baseColor; } function applyXlsxTint(hexColor: string, tint: number) { const channels = [0, 2, 4].map((offset) => Number.parseInt(hexColor.slice(offset, offset + 2), 16)); const tinted = channels.map((channel) => { const value = tint < 0 ? channel * (1 + tint) : channel * (1 - tint) + 255 * tint; return Math.min(255, Math.max(0, Math.round(value))).toString(16).padStart(2, "0").toUpperCase(); }); return tinted.join(""); } function normalizedFontSize(value?: number) { if (!Number.isFinite(value) || !value || value <= 0) { return undefined; } const normalized = Math.round(value * 2) / 2; return normalized === 11 ? undefined : normalized; } function normalizedFontFamily(value?: string) { const normalized = value?.trim().replace(/["<>]/g, "").slice(0, 80) ?? ""; return normalized && normalized.toLowerCase() !== "calibri" ? normalized : ""; } function normalizedHorizontalAlign(value?: string) { const normalized = value?.trim().toLowerCase() ?? ""; return normalized === "left" || normalized === "center" || normalized === "right" ? normalized : ""; } function normalizedVerticalAlign(value?: string) { const normalized = value?.trim().toLowerCase() ?? ""; if (normalized === "center" || normalized === "middle") { return "middle"; } return normalized === "top" || normalized === "bottom" ? normalized : ""; } function xlsxVerticalAlignValue(value: CellFormat["verticalAlign"]) { return value === "middle" ? "center" : value; } function booleanAttribute(value: unknown) { const normalized = String(value ?? "").trim().toLowerCase(); return normalized === "1" || normalized === "true"; } function normalizedNumberFormat(value?: string) { const raw = value?.trim() ?? ""; if (!raw || raw.toLowerCase() === "general") { return ""; } return raw.slice(0, 255); } function xlsxNumberFormats(styleSheet: XmlRecord) { const formats = { ...builtinNumberFormats }; toArray(childRecord(styleSheet, "numFmts").numFmt).forEach((numFmt) => { const record = asRecord(numFmt); const id = numberAttribute(record.numFmtId); const rawCode = record.formatCode; const code = typeof rawCode === "string" || typeof rawCode === "number" ? normalizedNumberFormat(String(rawCode)) : ""; if (id && code) { formats[id] = code; } }); return formats; } function hasXmlElement(record: XmlRecord, key: string) { return Object.prototype.hasOwnProperty.call(record, key); } function numberAttribute(value: unknown) { const number = Number(value); return Number.isInteger(number) && number >= 0 ? number : 0; } async function readSharedStrings(zip: ZipInstance, parser: InstanceType) { const sharedStringsFile = zip.file("xl/sharedStrings.xml"); if (!sharedStringsFile) { return []; } const sharedStringsXml = parser.parse(await sharedStringsFile.async("string")); const sst = childRecord(sharedStringsXml, "sst"); return toArray(sst.si).map((item) => xmlNodeText(item)); } async function readOptionalWorkbookStyles(zip: ZipInstance, parser: InstanceType) { const stylesFile = zip.file("xl/styles.xml"); return stylesFile ? parser.parse(await stylesFile.async("string")) : undefined; } async function readOptionalSheetRelationships(zip: ZipInstance, parser: InstanceType, sheetPath: string) { const relationshipsPath = sheetRelationshipsPath(sheetPath); const file = zip.file(relationshipsPath); return file ? parser.parse(await file.async("string")) : undefined; } async function readOptionalRelationships(zip: ZipInstance, parser: InstanceType, partPath: string) { const relationshipsPath = partRelationshipsPath(partPath); const file = zip.file(relationshipsPath); return file ? parser.parse(await file.async("string")) : undefined; } async function readZipText(zip: ZipInstance, path: string) { const file = zip.file(path); if (!file) { throw new Error(`Не удалось открыть XLSX: отсутствует часть ${path}.`); } return file.async("string"); } async function readOptionalZipText(zip: ZipInstance, path: string) { return (await zip.file(path)?.async("string")) ?? ""; } function sheetRelationshipsPath(sheetPath: string) { return partRelationshipsPath(sheetPath); } function partRelationshipsPath(partPath: string) { const normalized = partPath.replace(/\\/g, "/"); const slashIndex = normalized.lastIndexOf("/"); const directory = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""; const fileName = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; return `${directory}/_rels/${fileName}.rels`; } function resolveWorkbookTarget(target: string) { const normalized = target.replace(/\\/g, "/").replace(/^\/+/, ""); return normalized.startsWith("xl/") ? normalized : `xl/${normalized}`; } function relationshipTargetsByType(relationshipsXml: unknown, type: string, sourcePartPath: string) { const relationships = childRecord(relationshipsXml, "Relationships"); const targets: Record = {}; toArray(relationships.Relationship).forEach((relationship) => { const record = asRecord(relationship); const id = String(record.Id || ""); const relationshipType = String(record.Type || ""); const target = String(record.Target || ""); if (id && relationshipType === type && target) { targets[id] = resolvePartTarget(partDirectory(sourcePartPath), target); } }); return targets; } function resolvePartTarget(baseDirectory: string, target: string) { const normalizedTarget = target.replace(/\\/g, "/"); const rawPath = normalizedTarget.startsWith("/") ? normalizedTarget.replace(/^\/+/, "") : `${baseDirectory}/${normalizedTarget}`; const segments: string[] = []; rawPath.split("/").forEach((segment) => { if (!segment || segment === ".") { return; } if (segment === "..") { segments.pop(); return; } segments.push(segment); }); return segments.join("/"); } function partDirectory(path: string) { return path.replace(/\\/g, "/").replace(/\/[^/]*$/, ""); } function sortedHyperlinkEntries(hyperlinks: Record = {}) { return Object.entries(hyperlinks) .map(([address, target]) => normalizedHyperlinkEntry(address, target)) .filter((entry): entry is HyperlinkEntry => Boolean(entry)) .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.address === entry.address) === index) .sort((first, second) => { const firstRow = cellRowNumber(first.address); const secondRow = cellRowNumber(second.address); if (firstRow !== secondRow) { return firstRow - secondRow; } return cellColumnNumber(first.address) - cellColumnNumber(second.address); }); } function sortedExternalHyperlinkEntries(hyperlinks: Record = {}) { return sortedHyperlinkEntries(hyperlinks).filter((entry) => entry.external); } function normalizedHyperlinkEntry(address: string, target: string): HyperlinkEntry | null { const normalizedAddress = normalizeCellAddress(address); const externalTarget = normalizedHyperlinkTarget(target); if (normalizedAddress && externalTarget) { return { address: normalizedAddress, target: externalTarget, external: true }; } const location = normalizedInternalHyperlinkLocation(target); return normalizedAddress && location ? { address: normalizedAddress, target: `#${location}`, external: false, location } : null; } function normalizeCellAddress(address: string) { const trimmed = address.trim().toUpperCase(); return isCellAddress(trimmed) ? trimmed : ""; } function translateSharedFormula(formula: string, baseAddress: string, targetAddress: string) { const normalizedBase = normalizeCellAddress(baseAddress); const normalizedTarget = normalizeCellAddress(targetAddress); if (!normalizedBase || !normalizedTarget || normalizedBase === normalizedTarget) { return formula; } const columnOffset = cellColumnNumber(normalizedTarget) - cellColumnNumber(normalizedBase); const rowOffset = cellRowNumber(normalizedTarget) - cellRowNumber(normalizedBase); return formula .split(/("[^"]*(?:""[^"]*)*"|'[^']*(?:''[^']*)*')/g) .map((segment, index) => (index % 2 === 1 ? segment : translateFormulaReferences(segment, columnOffset, rowOffset))) .join(""); } function translateFormulaReferences(formula: string, columnOffset: number, rowOffset: number) { return formula.replace(/(^|[^A-Z0-9_])(\$?[A-Z]{1,3}\$?[1-9][0-9]*)(?![A-Z0-9_])/gi, (match, prefix: string, reference: string) => { const translated = translateFormulaReference(reference, columnOffset, rowOffset); return translated ? `${prefix}${translated}` : match; }); } function translateFormulaReference(reference: string, columnOffset: number, rowOffset: number) { const match = /^(\$?)([A-Z]{1,3})(\$?)([1-9][0-9]*)$/i.exec(reference); if (!match) { return ""; } const [, columnLock, columnNameValue, rowLock, rowValue] = match; const columnNumber = cellColumnNumber(`${columnNameValue}1`); const rowNumber = Number(rowValue); const translatedColumn = columnLock ? columnNumber : columnNumber + columnOffset; const translatedRow = rowLock ? rowNumber : rowNumber + rowOffset; if (translatedColumn < 1 || translatedRow < 1) { return reference; } return `${columnLock}${columnName(translatedColumn)}${rowLock}${translatedRow}`; } function normalizeChartRange(range: string) { const raw = range.trim().replace(/\$/g, "").toUpperCase(); const [start, end = start] = raw.split(":"); const normalizedStart = normalizeCellAddress(start); const normalizedEnd = normalizeCellAddress(end); if (!normalizedStart || !normalizedEnd) { return ""; } const normalized = normalizeMergeRange({ start: normalizedStart, end: normalizedEnd }); return normalized ? `${normalized.start}:${normalized.end}` : `${normalizedStart}:${normalizedStart}`; } function uniqueNumbers(values: number[]) { return [...new Set(values)].sort((first, second) => first - second); } function normalizeWhitespace(value: string) { return value.replace(/\s+/g, " ").trim(); } function sortedCellEntries(cells: Record) { return Object.entries(cells) .filter(([address, value]) => isCellAddress(address) && value !== "") .sort(([firstAddress], [secondAddress]) => { const firstRow = cellRowNumber(firstAddress); const secondRow = cellRowNumber(secondAddress); if (firstRow !== secondRow) { return firstRow - secondRow; } return cellColumnNumber(firstAddress) - cellColumnNumber(secondAddress); }); } function sortedWorksheetEntries(cells: Record, cellFormats: Record = {}) { const entries = new Map(); Object.entries(cells).forEach(([address, value]) => { const normalizedAddress = normalizeCellAddress(address); if (normalizedAddress && value !== "") { entries.set(normalizedAddress, value); } }); Object.entries(cellFormats).forEach(([address, format]) => { const normalizedAddress = normalizeCellAddress(address); if (normalizedAddress && normalizedCellFormat(format) && !entries.has(normalizedAddress)) { entries.set(normalizedAddress, ""); } }); return [...entries.entries()].sort(([firstAddress], [secondAddress]) => { const firstRow = cellRowNumber(firstAddress); const secondRow = cellRowNumber(secondAddress); if (firstRow !== secondRow) { return firstRow - secondRow; } return cellColumnNumber(firstAddress) - cellColumnNumber(secondAddress); }); } function isCellAddress(address: string) { return /^[A-Z]+[1-9][0-9]*$/i.test(address); } function normalizeMergeRef(ref: string) { const [start, end = start] = ref.split(":"); return normalizeMergeRange({ start, end }); } function normalizeMergeRange(range: MergedCellRange): MergedCellRange | null { if (!isCellAddress(range.start) || !isCellAddress(range.end)) { return null; } const startColumn = cellColumnNumber(range.start); const endColumn = cellColumnNumber(range.end); const startRow = cellRowNumber(range.start); const endRow = cellRowNumber(range.end); const normalizedStart = cellAddress(Math.min(startColumn, endColumn), Math.min(startRow, endRow)); const normalizedEnd = cellAddress(Math.max(startColumn, endColumn), Math.max(startRow, endRow)); if (normalizedStart === normalizedEnd) { return null; } return { start: normalizedStart, end: normalizedEnd }; } function cellAddress(columnNumber: number, rowNumber: number) { return `${columnName(columnNumber)}${rowNumber}`; } function cellColumnNumber(address: string) { const letters = address.match(/^[A-Z]+/i)?.[0].toUpperCase() ?? "A"; return letters.split("").reduce((value, letter) => value * 26 + letter.charCodeAt(0) - 64, 0); } function cellRowNumber(address: string) { return Number(address.match(/[1-9][0-9]*$/)?.[0] ?? 1); } function columnName(columnNumber: number) { let value = columnNumber; let name = ""; while (value > 0) { const remainder = (value - 1) % 26; name = String.fromCharCode(65 + remainder) + name; value = Math.floor((value - 1) / 26); } return name || "A"; } function xmlNodeText(node: unknown): string { if (node === null || node === undefined) { return ""; } if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") { return String(node); } if (Array.isArray(node)) { return node.map(xmlNodeText).join(""); } if (typeof node !== "object") { return ""; } const record = node as XmlRecord; if (record["#text"] !== undefined) { return xmlNodeText(record["#text"]); } if (record.t !== undefined) { return xmlNodeText(record.t); } if (record.r !== undefined) { return xmlNodeText(record.r); } return ""; } function formulaNodeText(node: unknown): string { if (node === null || node === undefined) { return ""; } if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") { return String(node); } if (Array.isArray(node)) { return node.map(formulaNodeText).join(""); } if (typeof node !== "object") { return ""; } return xmlNodeText((node as XmlRecord)["#text"]); } function childRecord(parent: unknown, key: string): XmlRecord { const record = asRecord(parent); return asRecord(record[key]); } function firstRecordByLocalName(node: unknown, localName: string): XmlRecord { return recordsByLocalName(node, localName)[0] ?? {}; } function textValuesByLocalName(node: unknown, localName: string): string[] { const values: string[] = []; collectTextValuesByLocalName(node, localName, values); return values.map(normalizeWhitespace).filter(Boolean); } function collectTextValuesByLocalName(node: unknown, localName: string, values: string[]): void { if (node === null || node === undefined) { return; } if (Array.isArray(node)) { node.forEach((item) => collectTextValuesByLocalName(item, localName, values)); return; } if (typeof node !== "object") { return; } Object.entries(node as XmlRecord).forEach(([key, value]) => { if (key.split(":").pop() === localName) { values.push(xmlNodeText(value)); } collectTextValuesByLocalName(value, localName, values); }); } function recordsByLocalName(node: unknown, localName: string): XmlRecord[] { const records: XmlRecord[] = []; collectRecordsByLocalName(node, localName, records); return records; } function collectRecordsByLocalName(node: unknown, localName: string, records: XmlRecord[]): void { if (node === null || node === undefined) { return; } if (Array.isArray(node)) { node.forEach((item) => collectRecordsByLocalName(item, localName, records)); return; } if (typeof node !== "object") { return; } Object.entries(node as XmlRecord).forEach(([key, value]) => { if (key.split(":").pop() === localName) { toArray(value).forEach((item) => { const record = asRecord(item); if (Object.keys(record).length > 0) { records.push(record); } }); } collectRecordsByLocalName(value, localName, records); }); } function asRecord(value: unknown): XmlRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as XmlRecord) : {}; } function toArray(value: unknown): unknown[] { if (value === undefined || value === null) { return []; } return Array.isArray(value) ? value : [value]; } function isPlainNumber(value: string) { const trimmed = value.trim(); if (trimmed !== value || trimmed === "") { return false; } if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) { return false; } return Number.isFinite(Number(value)); } function normalizedHyperlinkTarget(value?: string | null) { const raw = value?.trim(); if (!raw) { return ""; } try { const url = new URL(raw); return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : ""; } catch { return ""; } } function normalizedInternalHyperlinkTarget(value?: string | null, allowWithoutHash = false) { const location = normalizedInternalHyperlinkLocation(value, allowWithoutHash); return location ? `#${location}` : ""; } function normalizedInternalHyperlinkLocation(value?: string | null, allowWithoutHash = false) { const raw = value?.trim() ?? ""; const location = raw.startsWith("#") ? raw.slice(1).trim() : allowWithoutHash ? raw : ""; if (!location || /[\u0000-\u001F]/.test(location)) { return ""; } return location.slice(0, 2048); } function sharedFormulaKey(formulaRecord: XmlRecord) { return formulaRecord.t === "shared" && formulaRecord.si !== undefined ? String(formulaRecord.si) : ""; } function textElement(value: string) { const preserveSpace = /^\s|\s$/.test(value) ? ' xml:space="preserve"' : ""; return `${escapeXmlText(value)}`; } function xmlDeclaration(body: string) { return `${body}`; } function escapeXmlText(value: string) { return value.replace(/&/g, "&").replace(//g, ">"); } function escapeXmlAttribute(value: string) { return escapeXmlText(value).replace(/"/g, """).replace(/'/g, "'"); } async function loadJsZip() { const module = (await import("jszip")) as unknown as { default?: ZipConstructor } & ZipConstructor; return module.default ?? module; } async function loadXmlParser() { const module = (await import("fast-xml-parser")) as FastXmlParserModule; return module.XMLParser; }