Files
QOffice/src/io/excellOffice.ts
T
2026-06-01 17:35:06 +03:00

2130 lines
78 KiB
TypeScript

import type { CellFormat, MergedCellRange, SheetChart, SheetChartType, SheetFrozenPane, Workbook } from "../types";
import { evaluateCell, expandRange } from "../utils/spreadsheet";
import { officeTitleFromFileName, readOfficeFileAsArrayBuffer, XLSX_MIME_TYPE } from "./fileHelpers";
export interface ImportedExcellWorkbook {
title: string;
activeSheet?: string;
sheets: Array<{
id: string;
name: string;
visibility?: "hidden" | "veryHidden";
frozenPane?: SheetFrozenPane;
autoFilter?: string;
tabColor?: string;
columnWidths?: Record<string, number>;
rowHeights?: Record<string, number>;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
cellFormats?: Record<string, CellFormat>;
charts?: SheetChart[];
}>;
}
type XmlRecord = Record<string, unknown>;
type ZipObject = {
async: (type: "string") => Promise<string>;
};
type ZipInstance = {
files?: Record<string, unknown>;
file: {
(path: string): ZipObject | null;
(path: string, data: string): ZipInstance;
};
generateAsync: (options: { type: "blob"; mimeType: string }) => Promise<Blob>;
};
type ZipConstructor = {
new (): ZipInstance;
loadAsync: (data: ArrayBuffer) => Promise<ZipInstance>;
};
type XmlParserConstructor = new (options: Record<string, unknown>) => {
parse: (xml: string) => unknown;
};
type FastXmlParserModule = {
XMLParser: XmlParserConstructor;
};
interface WorkbookSheetRef {
id: string;
name: string;
visibility?: "hidden" | "veryHidden";
relationshipId: string;
}
interface ParsedSheet {
id: string;
name: string;
visibility?: "hidden" | "veryHidden";
frozenPane?: SheetFrozenPane;
autoFilter?: string;
tabColor?: string;
columnWidths?: Record<string, number>;
rowHeights?: Record<string, number>;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
cellFormats?: Record<string, CellFormat>;
charts?: SheetChart[];
}
type NormalizedFrozenPane = {
columns: number;
rows: number;
topLeftCell: string;
activePane: NonNullable<SheetFrozenPane["activePane"]>;
};
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<string, string | SharedFormulaDefinition>;
export interface CellStyleRegistry {
formats: CellFormat[];
idsByKey: Record<string, number>;
}
interface ChartPartRef {
sheetIndex: number;
drawingIndex: number;
chartIndex: number;
relationshipId: string;
chart: SheetChart;
cells: Record<string, string>;
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<number, string> = {
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<number, string> = {
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<ImportedExcellWorkbook> {
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 themeColors = await readOptionalWorkbookThemeColors(zip, parser);
const styleTable = xlsxStylesXmlToCellFormatTable(await readOptionalWorkbookStyles(zip, parser), themeColors);
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, themeColors);
const charts = await worksheetXmlToCharts(zip, parser, sheetXml, sheetRelationshipsXml, sheetPath, parsedSheet.name);
return { ...parsedSheet, charts };
})
);
const activeSheet = workbookActiveSheetId(workbookXml, sheets);
return {
title: officeTitleFromFileName(file.name, "Книга QExcell.xlsx"),
...(activeSheet ? { activeSheet } : {}),
sheets
};
}
export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
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);
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
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) => ({
name: sheet.name.trim() || `Лист ${index + 1}`,
...(sheet.visibility ? { visibility: sheet.visibility } : {})
})),
activeSheetIndex
)
);
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,
sheet.frozenPane,
sheet.autoFilter,
sheet.tabColor,
sheet.columnWidths,
sheet.rowHeights
)
);
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<CellFormat | undefined> = [],
themeColors: string[] = defaultXlsxThemeColors
): ParsedSheet {
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
const tabColor = worksheetXmlToTabColor(worksheetXml, themeColors);
const columnWidths = worksheetXmlToColumnWidths(worksheetXml);
const rowHeights = worksheetXmlToRowHeights(worksheetXml);
return {
id: sheet.id || `sheet-${index + 1}`,
name: sheet.name || `Лист ${index + 1}`,
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
...(frozenPane ? { frozenPane } : {}),
...(autoFilter ? { autoFilter } : {}),
...(tabColor ? { tabColor } : {}),
...(Object.keys(columnWidths).length > 0 ? { columnWidths } : {}),
...(Object.keys(rowHeights).length > 0 ? { rowHeights } : {}),
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<string, string> = {};
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<CellFormat | undefined> = []) {
return worksheetCellRecords(worksheetXml).reduce<Record<string, CellFormat>>((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<string, string> {
const worksheet = childRecord(worksheetXml, "worksheet");
const hyperlinks = childRecord(worksheet, "hyperlinks");
const relationshipTargets = worksheetRelationshipTargets(relationshipsXml);
return toArray(hyperlinks.hyperlink).reduce<Record<string, string>>((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;
}, {});
}
export function worksheetXmlToFrozenPane(worksheetXml: unknown): SheetFrozenPane | undefined {
const worksheet = childRecord(worksheetXml, "worksheet");
const sheetViews = childRecord(worksheet, "sheetViews");
const sheetView = asRecord(toArray(sheetViews.sheetView)[0]);
const pane = childRecord(sheetView, "pane");
const state = String(pane.state ?? "").trim().toLowerCase();
if (state !== "frozen" && state !== "frozensplit") {
return undefined;
}
const columns = paneSplitCount(pane.xSplit);
const rows = paneSplitCount(pane.ySplit);
if (columns === 0 && rows === 0) {
return undefined;
}
const topLeftCell = normalizeCellAddress(String(pane.topLeftCell ?? "")) || frozenPaneTopLeftCell(columns, rows);
const activePane = normalizedFrozenPaneActivePane(pane.activePane) || frozenPaneActivePane(columns, rows);
return {
...(columns > 0 ? { columns } : {}),
...(rows > 0 ? { rows } : {}),
topLeftCell,
activePane
};
}
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
const worksheet = childRecord(worksheetXml, "worksheet");
const autoFilter = childRecord(worksheet, "autoFilter");
return normalizeCellRange(String(autoFilter.ref ?? ""));
}
export function worksheetXmlToTabColor(worksheetXml: unknown, themeColors: string[] = defaultXlsxThemeColors) {
const worksheet = childRecord(worksheetXml, "worksheet");
const sheetPr = childRecord(worksheet, "sheetPr");
return colorRecordToHex(childRecord(sheetPr, "tabColor"), themeColors);
}
export function worksheetXmlToColumnWidths(worksheetXml: unknown): Record<string, number> {
const worksheet = childRecord(worksheetXml, "worksheet");
const cols = childRecord(worksheet, "cols");
return toArray(cols.col).reduce<Record<string, number>>((widths, col) => {
const record = asRecord(col);
const min = positiveIntegerAttribute(record.min);
const max = positiveIntegerAttribute(record.max) || min;
const width = positiveNumberAttribute(record.width);
if (!min || !max || !width || max < min) {
return widths;
}
for (let column = min; column <= max; column += 1) {
widths[columnName(column)] = width;
}
return widths;
}, {});
}
export function worksheetXmlToRowHeights(worksheetXml: unknown): Record<string, number> {
const worksheet = childRecord(worksheetXml, "worksheet");
const sheetData = childRecord(worksheet, "sheetData");
return toArray(sheetData.row).reduce<Record<string, number>>((heights, row, index) => {
const record = asRecord(row);
const rowNumber = positiveIntegerAttribute(record.r) || index + 1;
const height = positiveNumberAttribute(record.ht);
if (height) {
heights[String(rowNumber)] = height;
}
return heights;
}, {});
}
async function worksheetXmlToCharts(
zip: ZipInstance,
parser: InstanceType<XmlParserConstructor>,
worksheetXml: unknown,
relationshipsXml: unknown,
sheetPath: string,
sheetName: string
): Promise<SheetChart[]> {
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<XmlParserConstructor>,
drawingPath: string,
sheetName: string
): Promise<SheetChart[]> {
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<XmlParserConstructor>,
chartPath: string,
sheetName: string,
index: number
): Promise<SheetChart | null> {
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<string, string>,
mergedCells: MergedCellRange[] = [],
hyperlinks: Record<string, string> = {},
cellFormats: Record<string, CellFormat> = {},
styleRegistry = createCellStyleRegistry([cellFormats]),
drawingRelationshipId = "",
frozenPane?: SheetFrozenPane,
autoFilter?: string,
tabColor?: string,
columnWidths: Record<string, number> = {},
rowHeights: Record<string, number> = {}
) {
const normalizedRowHeights = normalizedSheetRowHeights(rowHeights);
const rows = sortedWorksheetEntries(cells, cellFormats).reduce<Record<number, Array<[string, string]>>>((grouped, entry) => {
const rowNumber = cellRowNumber(entry[0]);
if (!grouped[rowNumber]) {
grouped[rowNumber] = [];
}
grouped[rowNumber].push(entry);
return grouped;
}, {});
Object.keys(normalizedRowHeights).forEach((rowNumber) => {
const row = positiveIntegerAttribute(rowNumber);
if (row && !rows[row]) {
rows[row] = [];
}
});
const rowXml = Object.entries(rows)
.map(([rowNumber, entries]) => {
const cellXml = entries.map(([address, value]) => buildCellXml(address, value, cellStyleId(cellFormats[address], styleRegistry))).join("");
const rowAttributes = buildRowHeightAttributes(normalizedRowHeights[rowNumber]);
return cellXml ? `<row r="${rowNumber}"${rowAttributes}>${cellXml}</row>` : `<row r="${rowNumber}"${rowAttributes}/>`;
})
.join("");
const mergeCellsXml = buildMergeCellsXml(mergedCells);
const autoFilterXml = buildAutoFilterXml(autoFilter);
const hyperlinksXml = buildHyperlinksXml(hyperlinks);
const drawingXml = drawingRelationshipId ? `<drawing r:id="${escapeXmlAttribute(drawingRelationshipId)}"/>` : "";
const sheetPrXml = buildSheetPrXml(tabColor);
const sheetViewsXml = buildSheetViewsXml(frozenPane);
const colsXml = buildColumnWidthsXml(columnWidths);
return xmlDeclaration(
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetPrXml}${sheetViewsXml}${colsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}</worksheet>`
);
}
export function buildCellXml(address: string, value: string, styleId = 0) {
const cellAttributes = `r="${escapeXmlAttribute(address.toUpperCase())}"${styleId > 0 ? ` s="${styleId}"` : ""}`;
if (value === "") {
return `<c ${cellAttributes}/>`;
}
if (value.startsWith("=") && value.trim().length > 1) {
return `<c ${cellAttributes}><f>${escapeXmlText(value.slice(1))}</f></c>`;
}
if (isPlainNumber(value)) {
return `<c ${cellAttributes}><v>${value}</v></c>`;
}
return `<c ${cellAttributes} t="inlineStr"><is>${textElement(value)}</is></c>`;
}
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) => `<mergeCell ref="${escapeXmlAttribute(ref)}"/>`).join("");
return `<mergeCells count="${refs.length}">${mergeCellXml}</mergeCells>`;
}
export function buildHyperlinksXml(hyperlinks: Record<string, string> = {}) {
const refs = sortedHyperlinkEntries(hyperlinks);
if (refs.length === 0) {
return "";
}
let relationshipIndex = 0;
const hyperlinkXml = refs
.map((entry) => {
if (!entry.external) {
return `<hyperlink ref="${escapeXmlAttribute(entry.address)}" location="${escapeXmlAttribute(entry.location ?? "")}"/>`;
}
relationshipIndex += 1;
return `<hyperlink ref="${escapeXmlAttribute(entry.address)}" r:id="rId${relationshipIndex}"/>`;
})
.join("");
return `<hyperlinks>${hyperlinkXml}</hyperlinks>`;
}
export function buildSheetViewsXml(frozenPane?: SheetFrozenPane) {
const pane = normalizedFrozenPane(frozenPane);
if (!pane) {
return "";
}
const attributes = [
pane.columns > 0 ? `xSplit="${pane.columns}"` : "",
pane.rows > 0 ? `ySplit="${pane.rows}"` : "",
`topLeftCell="${escapeXmlAttribute(pane.topLeftCell)}"`,
`activePane="${pane.activePane}"`,
'state="frozen"'
]
.filter(Boolean)
.join(" ");
return `<sheetViews><sheetView workbookViewId="0"><pane ${attributes}/></sheetView></sheetViews>`;
}
export function buildSheetPrXml(tabColor?: string) {
const color = normalizeFillColor(tabColor);
return color ? `<sheetPr><tabColor rgb="FF${color}"/></sheetPr>` : "";
}
export function buildColumnWidthsXml(columnWidths: Record<string, number> = {}) {
const columns = normalizedSheetColumnWidths(columnWidths);
if (columns.length === 0) {
return "";
}
const xml = columns
.map(
({ column, width }) =>
`<col min="${column}" max="${column}" width="${formatSheetDimension(width)}" customWidth="1"/>`
)
.join("");
return `<cols>${xml}</cols>`;
}
export function buildAutoFilterXml(autoFilter?: string) {
const ref = normalizeCellRange(autoFilter ?? "");
return ref ? `<autoFilter ref="${escapeXmlAttribute(ref)}"/>` : "";
}
export function buildWorksheetRelationshipsXml(hyperlinks: Record<string, string> = {}, drawingTarget = "") {
const refs = sortedExternalHyperlinkEntries(hyperlinks);
if (refs.length === 0 && !drawingTarget) {
return "";
}
const relationships = refs
.map(
(entry, index) =>
`<Relationship Id="rId${index + 1}" Type="${hyperlinkRelationshipType}" Target="${escapeXmlAttribute(entry.target)}" TargetMode="External"/>`
)
.join("");
const drawingRelationship = drawingTarget
? `<Relationship Id="rId${refs.length + 1}" Type="${drawingRelationshipType}" Target="${escapeXmlAttribute(drawingTarget)}"/>`
: "";
return xmlDeclaration(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${relationships}${drawingRelationship}</Relationships>`);
}
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);
const visibility = workbookSheetVisibility(record.state);
return {
id: `sheet-${String(record.sheetId || index + 1)}`,
name: String(record.name || `Лист ${index + 1}`),
...(visibility ? { visibility } : {}),
relationshipId: String(record["r:id"] || "")
};
});
}
function workbookSheetVisibility(value: unknown): WorkbookSheetRef["visibility"] {
const normalized = String(value ?? "").trim().toLowerCase();
if (normalized === "hidden") {
return "hidden";
}
if (normalized === "veryhidden") {
return "veryHidden";
}
return undefined;
}
export function workbookActiveSheetId(workbookXml: unknown, sheets: Array<{ id: string }>): string {
const workbook = childRecord(workbookXml, "workbook");
const bookViews = childRecord(workbook, "bookViews");
const workbookView = asRecord(toArray(bookViews.workbookView)[0]);
const activeTab = Number.parseInt(String(workbookView.activeTab ?? workbookView["@_activeTab"] ?? ""), 10);
if (!Number.isInteger(activeTab) || activeTab < 0) {
return "";
}
return sheets[activeTab]?.id ?? "";
}
export function workbookRelationshipTargets(relationshipsXml: unknown) {
const relationships = childRecord(relationshipsXml, "Relationships");
const targets: Record<string, string> = {};
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<string, string> = {};
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 `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
}).join("");
const drawingOverrides = uniqueNumbers(chartParts.map((part) => part.drawingIndex))
.map((drawingIndex) => `<Override PartName="/xl/drawings/drawing${drawingIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>`)
.join("");
const chartOverrides = chartParts
.map((part) => `<Override PartName="/xl/charts/chart${part.chartIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`)
.join("");
return xmlDeclaration(
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheetOverrides}${drawingOverrides}${chartOverrides}</Types>`
);
}
function buildRootRelationshipsXml() {
return xmlDeclaration(
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>`
);
}
function buildWorkbookXml(sheets: Array<{ name: string; visibility?: WorkbookSheetRef["visibility"] }>, activeSheetIndex = 0) {
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;
const sheetXml = sheets
.map((sheet, index) => {
const visibility = sheet.visibility ? ` state="${sheet.visibility}"` : "";
return `<sheet name="${escapeXmlAttribute(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"${visibility}/>`;
})
.join("");
return xmlDeclaration(
`<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${bookViews}<sheets>${sheetXml}</sheets></workbook>`
);
}
function buildWorkbookRelationshipsXml(sheetCount: number) {
const sheetRelationships = Array.from({ length: sheetCount }, (_, index) => {
return `<Relationship Id="rId${index + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`;
}).join("");
const stylesRelationshipId = sheetCount + 1;
return xmlDeclaration(
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheetRelationships}<Relationship Id="rId${stylesRelationshipId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`
);
}
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 `<xdr:twoCellAnchor><xdr:from><xdr:col>6</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>${topRow}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from><xdr:to><xdr:col>13</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>${bottomRow}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to><xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="${index + 2}" name="${escapeXmlAttribute(part.chart.title)}"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="5486400" cy="3200400"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="${part.relationshipId}"/></a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>`;
})
.join("");
return xmlDeclaration(
`<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${anchors}</xdr:wsDr>`
);
}
function buildDrawingRelationshipsXml(chartParts: ChartPartRef[]) {
const relationships = chartParts
.map(
(part) =>
`<Relationship Id="${part.relationshipId}" Type="${chartRelationshipType}" Target="../charts/chart${part.chartIndex}.xml"/>`
)
.join("");
return xmlDeclaration(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${relationships}</Relationships>`);
}
export function buildChartXml(chart: SheetChart, sheetName: string, cells: Record<string, string> = {}) {
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"
? `<c:lineChart><c:grouping val="standard"/>${seriesXml}<c:marker val="1"/><c:axId val="123456"/><c:axId val="123457"/></c:lineChart>${buildChartAxesXml()}`
: normalized.type === "pie"
? `<c:pieChart><c:varyColors val="1"/>${seriesXml}<c:firstSliceAng val="0"/></c:pieChart>`
: `<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>${seriesXml}<c:axId val="123456"/><c:axId val="123457"/></c:barChart>${buildChartAxesXml()}`;
return xmlDeclaration(
`<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><c:lang val="ru-RU"/><c:chart><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>${escapeXmlText(normalized.title)}</a:t></a:r></a:p></c:rich></c:tx><c:layout/></c:title><c:plotArea><c:layout/>${plotXml}</c:plotArea><c:legend><c:legendPos val="r"/><c:layout/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>`
);
}
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) => `<c:pt idx="${index}"><c:v>${escapeXmlText(point.label)}</c:v></c:pt>`)
.join("");
const valueCache = points
.map((point, index) => `<c:pt idx="${index}"><c:v>${point.value}</c:v></c:pt>`)
.join("");
return `<c:ser><c:idx val="0"/><c:order val="0"/><c:tx><c:v>${escapeXmlText(chart.title)}</c:v></c:tx><c:cat><c:strRef><c:f>${escapeXmlText(labelFormula)}</c:f><c:strCache><c:ptCount val="${points.length}"/>${labelCache}</c:strCache></c:strRef></c:cat><c:val><c:numRef><c:f>${escapeXmlText(valueFormula)}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="${points.length}"/>${valueCache}</c:numCache></c:numRef></c:val></c:ser>`;
}
function buildChartAxesXml() {
return `<c:catAx><c:axId val="123456"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="123457"/><c:crosses val="autoZero"/></c:catAx><c:valAx><c:axId val="123457"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:axPos val="l"/><c:majorGridlines/><c:numFmt formatCode="General" sourceLinked="1"/><c:tickLblPos val="nextTo"/><c:crossAx val="123456"/><c:crosses val="autoZero"/></c:valAx>`;
}
export function chartDataPoints(chart: SheetChart, cells: Record<string, string>) {
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<Record<string, CellFormat> | undefined>): CellStyleRegistry {
const formats: CellFormat[] = [];
const idsByKey: Record<string, number> = {};
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,
themeColors: string[] = defaultXlsxThemeColors
): Array<CellFormat | undefined> {
const styleSheet = childRecord(stylesXml, "styleSheet");
const numberFormats = xlsxNumberFormats(styleSheet);
const fonts = toArray(childRecord(styleSheet, "fonts").font).map((font, index) => fontXmlToCellFormat(font, index, themeColors));
const fills = toArray(childRecord(styleSheet, "fills").fill).map((fill) => fillXmlToCellFormat(fill, themeColors));
const borders = toArray(childRecord(styleSheet, "borders").border).map((border) => borderXmlToCellFormat(border, themeColors));
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 = ['<fill><patternFill patternType="none"/></fill>', '<fill><patternFill patternType="gray125"/></fill>'];
const borders = [baseBorderXml()];
const fontIdsByKey: Record<string, number> = {};
const fillIdsByColor: Record<string, number> = {};
const borderIdsByColor: Record<string, number> = {};
const numberFormatIdsByCode: Record<string, number> = {};
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 ? `<alignment ${alignmentAttributes}/>` : "";
return `<xf numFmtId="${numFmtId}" fontId="${fontId}" fillId="${fillId}" borderId="${borderId}" xfId="0"${applyFont}${applyFill}${applyBorder}${applyAlignment}${applyNumberFormat}>${alignmentXml}</xf>`;
});
const numFmtsXml =
customNumberFormats.length > 0
? `<numFmts count="${customNumberFormats.length}">${customNumberFormats
.map(([id, code]) => `<numFmt numFmtId="${id}" formatCode="${escapeXmlAttribute(code)}"/>`)
.join("")}</numFmts>`
: "";
return xmlDeclaration(
`<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">${numFmtsXml}<fonts count="${fonts.length}">${fonts.join("")}</fonts><fills count="${fills.length}">${fills.join("")}</fills><borders count="${borders.length}">${borders.join("")}</borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="${cellXfs.length + 1}"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>${cellXfs.join("")}</cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`
);
}
function buildAppPropertiesXml() {
return xmlDeclaration(
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>QOffice</Application></Properties>`
);
}
function buildCorePropertiesXml() {
const now = new Date().toISOString();
return xmlDeclaration(
`<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:creator>QOffice</dc:creator><cp:lastModifiedBy>QOffice</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${now}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified></cp:coreProperties>`
);
}
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, themeColors: string[] = defaultXlsxThemeColors): CellFormat {
const record = asRecord(font);
const textColor = colorRecordToHex(childRecord(record, "color"), themeColors, { 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, themeColors: string[] = defaultXlsxThemeColors): CellFormat {
const patternFill = childRecord(fill, "patternFill");
const fillColor = colorRecordToHex(childRecord(patternFill, "fgColor"), themeColors);
return fillColor ? { fillColor } : {};
}
function borderXmlToCellFormat(border: unknown, themeColors: string[] = defaultXlsxThemeColors): 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"), themeColors))
.find(Boolean);
return normalizedCellFormat({
border: borderedSides.length > 0,
...(borderColor ? { borderColor } : {})
}) ?? {};
}
function baseFontXml() {
return '<font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font>';
}
function formatFontXml(format: CellFormat) {
const flags = `${format.bold ? "<b/>" : ""}${format.italics ? "<i/>" : ""}${format.underline ? "<u/>" : ""}${format.strike ? "<strike/>" : ""}`;
const color = format.textColor ? `<color rgb="FF${format.textColor}"/>` : "";
return `<font>${flags}<sz val="${format.fontSize ?? 11}"/>${color}<name val="${escapeXmlAttribute(format.fontFamily ?? "Calibri")}"/><family val="2"/></font>`;
}
function formatFillXml(fillColor: string) {
return `<fill><patternFill patternType="solid"><fgColor rgb="FF${fillColor}"/><bgColor indexed="64"/></patternFill></fill>`;
}
function baseBorderXml() {
return "<border><left/><right/><top/><bottom/><diagonal/></border>";
}
function formatBorderXml(borderColor = "") {
const colorXml = borderColor ? `<color rgb="FF${borderColor}"/>` : '<color auto="1"/>';
return `<border><left style="thin">${colorXml}</left><right style="thin">${colorXml}</right><top style="thin">${colorXml}</top><bottom style="thin">${colorXml}</bottom><diagonal/></border>`;
}
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,
themeColors: string[] = defaultXlsxThemeColors,
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 ?? "")), themeColors);
if (themeColor) {
return themeColor;
}
}
return record.indexed !== undefined ? indexedXlsxColors[numberAttribute(record.indexed)] ?? "" : "";
}
function themeColorToHex(themeIndex: number, tint = 0, themeColors: string[] = defaultXlsxThemeColors) {
const baseColor = themeColors[themeIndex] || 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;
}
function positiveIntegerAttribute(value: unknown) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : 0;
}
function positiveNumberAttribute(value: unknown) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function normalizedSheetColumnWidths(columnWidths: Record<string, number> = {}) {
return Object.entries(columnWidths)
.map(([column, width]) => {
const columnNumber = columnAddressNumber(column);
const normalizedWidth = positiveNumberAttribute(width);
return columnNumber && normalizedWidth ? { column: columnNumber, width: normalizedWidth } : null;
})
.filter((entry): entry is { column: number; width: number } => Boolean(entry))
.sort((first, second) => first.column - second.column);
}
function normalizedSheetRowHeights(rowHeights: Record<string, number> = {}) {
return Object.entries(rowHeights).reduce<Record<string, number>>((heights, [row, height]) => {
const rowNumber = positiveIntegerAttribute(row);
const normalizedHeight = positiveNumberAttribute(height);
if (rowNumber && normalizedHeight) {
heights[String(rowNumber)] = normalizedHeight;
}
return heights;
}, {});
}
function buildRowHeightAttributes(height?: number) {
return height ? ` ht="${formatSheetDimension(height)}" customHeight="1"` : "";
}
function formatSheetDimension(value: number) {
return Number(value.toFixed(4)).toString();
}
function normalizedFrozenPane(pane?: SheetFrozenPane): NormalizedFrozenPane | null {
const columns = paneSplitCount(pane?.columns);
const rows = paneSplitCount(pane?.rows);
if (columns === 0 && rows === 0) {
return null;
}
const topLeftCell = normalizeCellAddress(pane?.topLeftCell ?? "") || frozenPaneTopLeftCell(columns, rows);
const activePane = normalizedFrozenPaneActivePane(pane?.activePane) || frozenPaneActivePane(columns, rows);
return {
columns,
rows,
topLeftCell,
activePane
};
}
function paneSplitCount(value: unknown) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
}
function frozenPaneTopLeftCell(columns: number, rows: number) {
return cellAddress(columns + 1, rows + 1);
}
function frozenPaneActivePane(columns: number, rows: number): NormalizedFrozenPane["activePane"] {
if (columns > 0 && rows > 0) {
return "bottomRight";
}
return columns > 0 ? "topRight" : "bottomLeft";
}
function normalizedFrozenPaneActivePane(value: unknown): NormalizedFrozenPane["activePane"] | "" {
return value === "topRight" || value === "bottomLeft" || value === "bottomRight" ? value : "";
}
async function readSharedStrings(zip: ZipInstance, parser: InstanceType<XmlParserConstructor>) {
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<XmlParserConstructor>) {
const stylesFile = zip.file("xl/styles.xml");
return stylesFile ? parser.parse(await stylesFile.async("string")) : undefined;
}
async function readOptionalWorkbookThemeColors(zip: ZipInstance, parser: InstanceType<XmlParserConstructor>) {
const themeFile = zip.file("xl/theme/theme1.xml");
if (!themeFile) {
return defaultXlsxThemeColors;
}
return xlsxThemeXmlToColors(parser.parse(await themeFile.async("string")));
}
export function xlsxThemeXmlToColors(themeXml?: unknown) {
const colorScheme = firstRecordByLocalName(themeXml, "clrScheme");
const slots = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];
const colors = slots.map((slot, index) => themeSlotColor(directChildByLocalName(colorScheme, slot)) || defaultXlsxThemeColors[index]);
return colors.some(Boolean) ? colors : defaultXlsxThemeColors;
}
function themeSlotColor(slot: XmlRecord) {
const srgbColor = firstRecordByLocalName(slot, "srgbClr");
const systemColor = firstRecordByLocalName(slot, "sysClr");
return normalizeFillColor(String(srgbColor.val || systemColor.lastClr || ""));
}
function directChildByLocalName(record: XmlRecord, localName: string) {
const entry = Object.entries(record).find(([key]) => key.split(":").pop() === localName);
return entry ? asRecord(toArray(entry[1])[0]) : {};
}
async function readOptionalSheetRelationships(zip: ZipInstance, parser: InstanceType<XmlParserConstructor>, 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<XmlParserConstructor>, 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<string, string> = {};
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<string, string> = {}) {
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<string, string> = {}) {
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 columnAddressNumber(column: string) {
const normalized = column.trim().toUpperCase();
return /^[A-Z]+$/.test(normalized) ? cellColumnNumber(`${normalized}1`) : 0;
}
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 normalizeCellRange(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;
}
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<string, string>) {
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<string, string>, cellFormats: Record<string, CellFormat> = {}) {
const entries = new Map<string, string>();
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 `<t${preserveSpace}>${escapeXmlText(value)}</t>`;
}
function xmlDeclaration(body: string) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>${body}`;
}
function escapeXmlText(value: string) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeXmlAttribute(value: string) {
return escapeXmlText(value).replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
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;
}