diff --git a/src/io/excellOffice.test.ts b/src/io/excellOffice.test.ts
index 0449133..012e8d1 100644
--- a/src/io/excellOffice.test.ts
+++ b/src/io/excellOffice.test.ts
@@ -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('');
+ expect(workbookXml).toContain('');
+ expect(workbookXml).toContain('\'Sales 2026 Q1\'!$A$1:$B$2');
+ 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(
diff --git a/src/io/excellOffice.ts b/src/io/excellOffice.ts
index 937cbb5..9814498 100644
--- a/src/io/excellOffice.ts
+++ b/src/io/excellOffice.ts
@@ -267,11 +267,13 @@ export async function exportXlsxBlob(workbook: Workbook): Promise {
const JSZip = await loadJsZip();
const zip = new JSZip();
const sheets = workbook.sheets.length > 0 ? workbook.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [], hyperlinks: {} }];
+ const 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 {
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 {
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();
+ return sheets.map((sheet, index) => uniqueXlsxSheetName(sheet.name, index, usedNames));
+}
+
+function uniqueXlsxSheetName(value: string, index: number, usedNames: Set) {
+ 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 = ``;