From 6251c70ddc7de1419a20f8390be47f5747d3c94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Tue, 2 Jun 2026 19:13:50 +0300 Subject: [PATCH] Normalize QExcell sheet names for XLSX export --- src/io/excellOffice.test.ts | 33 ++++++++++++++++++++++ src/io/excellOffice.ts | 55 ++++++++++++++++++++++++++++++++----- 2 files changed, 81 insertions(+), 7 deletions(-) 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 = ``;