2188 lines
80 KiB
TypeScript
2188 lines
80 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>;
|
|
showGridLines?: boolean;
|
|
zoomScale?: 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>;
|
|
showGridLines?: boolean;
|
|
zoomScale?: 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,
|
|
sheet.showGridLines,
|
|
sheet.zoomScale
|
|
)
|
|
);
|
|
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);
|
|
const showGridLines = worksheetXmlToShowGridLines(worksheetXml);
|
|
const zoomScale = worksheetXmlToZoomScale(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 } : {}),
|
|
...(showGridLines === false ? { showGridLines } : {}),
|
|
...(zoomScale ? { zoomScale } : {}),
|
|
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 worksheetXmlToShowGridLines(worksheetXml: unknown): boolean | undefined {
|
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
|
const sheetViews = childRecord(worksheet, "sheetViews");
|
|
const sheetView = asRecord(toArray(sheetViews.sheetView)[0]);
|
|
const value = sheetView.showGridLines;
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
return !isFalseXmlBoolean(value);
|
|
}
|
|
|
|
export function worksheetXmlToZoomScale(worksheetXml: unknown) {
|
|
const worksheet = childRecord(worksheetXml, "worksheet");
|
|
const sheetViews = childRecord(worksheet, "sheetViews");
|
|
const sheetView = asRecord(toArray(sheetViews.sheetView)[0]);
|
|
return normalizedSheetZoomScale(sheetView.zoomScale);
|
|
}
|
|
|
|
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> = {},
|
|
showGridLines?: boolean,
|
|
zoomScale?: 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, showGridLines, zoomScale);
|
|
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, showGridLines?: boolean, zoomScale?: number) {
|
|
const pane = normalizedFrozenPane(frozenPane);
|
|
const normalizedZoomScale = normalizedSheetZoomScale(zoomScale);
|
|
if (!pane && showGridLines !== false && !normalizedZoomScale) {
|
|
return "";
|
|
}
|
|
|
|
const sheetViewAttributes = [
|
|
"workbookViewId=\"0\"",
|
|
showGridLines === false ? 'showGridLines="0"' : "",
|
|
normalizedZoomScale ? `zoomScale="${normalizedZoomScale}"` : ""
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
if (!pane) {
|
|
return `<sheetViews><sheetView ${sheetViewAttributes}/></sheetViews>`;
|
|
}
|
|
|
|
const paneAttributes = [
|
|
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 ${sheetViewAttributes}><pane ${paneAttributes}/></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 isFalseXmlBoolean(value: unknown) {
|
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
return normalized === "0" || normalized === "false";
|
|
}
|
|
|
|
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 normalizedSheetZoomScale(value: unknown) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) {
|
|
return 0;
|
|
}
|
|
|
|
const zoomScale = Math.round(number);
|
|
return zoomScale >= 10 && zoomScale <= 400 && zoomScale !== 100 ? zoomScale : 0;
|
|
}
|
|
|
|
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, "&").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;
|
|
}
|