Normalize QExcell sheet names for XLSX export

This commit is contained in:
Курнат Андрей
2026-06-02 19:13:50 +03:00
parent e857beabd0
commit 6251c70ddc
2 changed files with 81 additions and 7 deletions
+33
View File
@@ -37,6 +37,7 @@ import {
worksheetXmlToZoomScale,
xlsxStylesXmlToCellFormatTable,
xlsxThemeXmlToColors,
xlsxSafeSheetNames,
xlsxCellToText
} from "./excellOffice";
@@ -633,6 +634,38 @@ describe("excellOffice OOXML helpers", () => {
expect(imported.activeSheet).toBe("sheet-2");
});
it("normalizes XLSX sheet names to Excel-safe unique names on export", async () => {
expect(
xlsxSafeSheetNames([
{ name: "Sales/2026:Q1*?" },
{ name: "'Very long worksheet name that exceeds thirty one characters'" },
{ name: "Sales 2026 Q1" },
{ name: "'[]:*?/\\'" },
{ name: "History" }
])
).toEqual(["Sales 2026 Q1", "Very long worksheet name that e", "Sales 2026 Q1 (2)", "Лист 4", "History 1"]);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Safe sheet names.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{ id: "sheet-1", name: "Sales/2026:Q1*?", printArea: "A1:B2", cells: { A1: "Sales" } },
{ id: "sheet-2", name: "Sales 2026 Q1", cells: { A1: "Duplicate" } }
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const workbookXml = await zip.file("xl/workbook.xml")?.async("string");
const imported = await importXlsxFile(new File([blob], "Safe sheet names.xlsx", { type: blob.type }));
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1" sheetId="1" r:id="rId1"/>');
expect(workbookXml).toContain('<sheet name="Sales 2026 Q1 (2)" sheetId="2" r:id="rId2"/>');
expect(workbookXml).toContain('<definedName name="_xlnm.Print_Area" localSheetId="0">\'Sales 2026 Q1\'!$A$1:$B$2</definedName>');
expect(imported.sheets.map((sheet) => sheet.name)).toEqual(["Sales 2026 Q1", "Sales 2026 Q1 (2)"]);
expect(imported.sheets[0].printArea).toBe("A1:B2");
});
it("сохраняет и импортирует область печати XLSX через definedName _xlnm.Print_Area", async () => {
expect(
workbookPrintAreas(
+48 -7
View File
@@ -267,11 +267,13 @@ 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 safeSheetNames = xlsxSafeSheetNames(sheets);
const safeSheets = sheets.map((sheet, index) => ({ ...sheet, name: safeSheetNames[index] }));
const styleRegistry = createCellStyleRegistry(sheets.map((sheet) => sheet.cellFormats));
const chartParts = chartPartRefsForSheets(sheets);
const commentParts = commentPartRefsForSheets(sheets);
const activeSheetIndex = Math.max(0, sheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
const styleRegistry = createCellStyleRegistry(safeSheets.map((sheet) => sheet.cellFormats));
const chartParts = chartPartRefsForSheets(safeSheets);
const commentParts = commentPartRefsForSheets(safeSheets);
const activeSheetIndex = Math.max(0, safeSheets.findIndex((sheet) => sheet.id === workbook.activeSheet));
zip.file("[Content_Types].xml", buildContentTypesXml(sheets.length, chartParts, commentParts));
zip.file("_rels/.rels", buildRootRelationshipsXml());
@@ -280,8 +282,8 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
zip.file(
"xl/workbook.xml",
buildWorkbookXml(
sheets.map((sheet, index) => ({
name: sheet.name.trim() || `Лист ${index + 1}`,
safeSheets.map((sheet) => ({
name: sheet.name,
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
...(sheet.printArea ? { printArea: sheet.printArea } : {})
})),
@@ -291,7 +293,7 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
zip.file("xl/_rels/workbook.xml.rels", buildWorkbookRelationshipsXml(sheets.length));
zip.file("xl/styles.xml", buildStylesXml(styleRegistry));
sheets.forEach((sheet, index) => {
safeSheets.forEach((sheet, index) => {
const sheetChartParts = chartParts.filter((part) => part.sheetIndex === index);
const sheetCommentPart = commentParts.find((part) => part.sheetIndex === index);
const externalHyperlinkCount = sortedExternalHyperlinkEntries(sheet.hyperlinks).length;
@@ -1109,6 +1111,45 @@ function buildRootRelationshipsXml() {
);
}
export function xlsxSafeSheetNames(sheets: Array<{ name: string }>) {
const usedNames = new Set<string>();
return sheets.map((sheet, index) => uniqueXlsxSheetName(sheet.name, index, usedNames));
}
function uniqueXlsxSheetName(value: string, index: number, usedNames: Set<string>) {
const fallback = `Лист ${index + 1}`;
const normalizedName = normalizedXlsxSheetName(value);
const baseName = xlsxReservedSheetName(normalizedName) ? `${normalizedName} 1` : normalizedName || fallback;
let suffixNumber = 1;
let candidate = truncateXlsxSheetName(baseName);
while (usedNames.has(candidate.toLowerCase())) {
suffixNumber += 1;
const suffix = ` (${suffixNumber})`;
candidate = `${truncateXlsxSheetName(baseName, 31 - suffix.length)}${suffix}`;
}
usedNames.add(candidate.toLowerCase());
return candidate;
}
function normalizedXlsxSheetName(value: string) {
return value
.replace(/[\u0000-\u001f\\/?*:[\]]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/^'+|'+$/g, "")
.trim();
}
function xlsxReservedSheetName(value: string) {
return value.trim().toLowerCase() === "history";
}
function truncateXlsxSheetName(value: string, maxLength = 31) {
return Array.from(value).slice(0, Math.max(1, maxLength)).join("");
}
function buildWorkbookXml(sheets: Array<{ name: string; visibility?: WorkbookSheetRef["visibility"]; printArea?: string }>, activeSheetIndex = 0) {
const activeTab = Math.min(Math.max(0, activeSheetIndex), Math.max(0, sheets.length - 1));
const bookViews = `<bookViews><workbookView activeTab="${activeTab}"/></bookViews>`;