Normalize QExcell sheet names for XLSX export
This commit is contained in:
@@ -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
@@ -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>`;
|
||||
|
||||
Reference in New Issue
Block a user