Files
QOffice/src/io/excellOffice.test.ts
T
Курнат Андрей 231bb6c076 Support XLSX 1904 date imports
2026-06-02 19:42:51 +03:00

1247 lines
47 KiB
TypeScript

import { describe, expect, it } from "vitest";
import JSZip from "jszip";
import {
buildChartXml,
buildCellXml,
buildAutoFilterXml,
buildCommentsXml,
buildColumnWidthsXml,
buildHyperlinksXml,
buildMergeCellsXml,
buildSheetPrXml,
buildSheetViewsXml,
createCellStyleRegistry,
buildWorksheetRelationshipsXml,
buildWorksheetXml,
exportXlsxBlob,
importXlsxFile,
chartDataPoints,
chartXmlToSheetChart,
workbookActiveSheetId,
workbookPrintAreas,
workbookRelationshipTargets,
workbookSheetRefs,
workbookUsesDate1904,
worksheetXmlToAutoFilter,
worksheetXmlToCells,
worksheetXmlToCellFormats,
worksheetXmlToColumnWidths,
worksheetXmlToFrozenPane,
worksheetXmlToHiddenColumns,
worksheetXmlToHiddenRows,
worksheetXmlToHyperlinks,
worksheetCommentsXmlToComments,
worksheetXmlToMergedCells,
worksheetXmlToRowHeights,
worksheetXmlToShowGridLines,
worksheetXmlToTabColor,
worksheetXmlToZoomScale,
xlsxStylesXmlToCellFormatTable,
xlsxThemeXmlToColors,
xlsxSafeSheetNames,
xlsxCellToText
} from "./excellOffice";
describe("excellOffice OOXML helpers", () => {
it("сохраняет формулу при импорте как строку с '='", () => {
expect(xlsxCellToText({ r: "C1", f: "SUM(A1:B1)", v: "3" }, [])).toBe("=SUM(A1:B1)");
expect(xlsxCellToText({ r: "C2", f: { "#text": "A2*2" }, v: "8" }, [])).toBe("=A2*2");
});
it("сдвигает относительные ссылки XLSX shared formulas при импорте", () => {
const cells = worksheetXmlToCells(
{
worksheet: {
sheetData: {
row: [
{
r: "1",
c: [{ r: "C1", f: { t: "shared", si: "0", ref: "C1:D2", "#text": "A1+B$1+$A1+$B$2+'A1 Sheet'!A1&\"A1\"" }, v: "0" }]
},
{
r: "2",
c: [
{ r: "C2", f: { t: "shared", si: "0" }, v: "0" },
{ r: "D2", f: { t: "shared", si: "0" }, v: "0" }
]
}
]
}
}
},
[]
);
expect(cells).toEqual({
C1: "=A1+B$1+$A1+$B$2+'A1 Sheet'!A1&\"A1\"",
C2: "=A2+B$1+$A2+$B$2+'A1 Sheet'!A2&\"A1\"",
D2: "=B2+C$1+$A2+$B$2+'A1 Sheet'!B2&\"A1\""
});
});
it("читает shared string, inline string, число и boolean", () => {
expect(xlsxCellToText({ r: "A1", t: "s", v: "1" }, ["Первый", "Второй"])).toBe("Второй");
expect(xlsxCellToText({ r: "A2", t: "inlineStr", is: { t: "Текст" } }, [])).toBe("Текст");
expect(xlsxCellToText({ r: "A3", v: "12500" }, [])).toBe("12500");
expect(xlsxCellToText({ r: "A4", t: "b", v: "1" }, [])).toBe("true");
});
it("записывает формулу в тег <f>", () => {
expect(buildCellXml("c1", "=SUM(A1:B1)")).toBe('<c r="C1"><f>SUM(A1:B1)</f></c>');
});
it("собирает XML листа с отсортированными ячейками", () => {
const xml = buildWorksheetXml({
B2: "200",
A1: "Статья",
C2: "=B2*2"
});
expect(xml).toContain('<row r="1"><c r="A1" t="inlineStr"><is><t>Статья</t></is></c></row>');
expect(xml).toContain('<row r="2"><c r="B2"><v>200</v></c><c r="C2"><f>B2*2</f></c></row>');
});
it("сохраняет merged cells в XML листа", () => {
const xml = buildWorksheetXml({ A1: "Заголовок" }, [{ start: "A1", end: "C2" }]);
expect(buildMergeCellsXml([{ start: "C2", end: "A1" }, { start: "A1", end: "C2" }])).toBe(
'<mergeCells count="1"><mergeCell ref="A1:C2"/></mergeCells>'
);
expect(xml).toContain('<mergeCells count="1"><mergeCell ref="A1:C2"/></mergeCells>');
});
it("сохраняет внешние гиперссылки XLSX в XML листа и relationships", () => {
const hyperlinks = {
A1: "https://example.com/report",
B2: "javascript:alert(1)"
};
const xml = buildWorksheetXml({ A1: "Отчет", B2: "Скрипт" }, [], hyperlinks);
const relationshipsXml = buildWorksheetRelationshipsXml(hyperlinks);
expect(buildHyperlinksXml(hyperlinks)).toBe('<hyperlinks><hyperlink ref="A1" r:id="rId1"/></hyperlinks>');
expect(xml).toContain('<hyperlinks><hyperlink ref="A1" r:id="rId1"/></hyperlinks>');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"');
expect(relationshipsXml).toContain('Target="https://example.com/report"');
expect(relationshipsXml).toContain('TargetMode="External"');
expect(relationshipsXml).not.toContain("javascript:alert");
});
it("сохраняет внутренние гиперссылки XLSX как location без external relationship", () => {
const hyperlinks = {
A1: "#Справочник!B2",
B2: "https://example.com/report"
};
const xml = buildWorksheetXml({ A1: "Справочник", B2: "Отчет" }, [], hyperlinks);
const relationshipsXml = buildWorksheetRelationshipsXml(hyperlinks);
expect(buildHyperlinksXml(hyperlinks)).toBe(
'<hyperlinks><hyperlink ref="A1" location="Справочник!B2"/><hyperlink ref="B2" r:id="rId1"/></hyperlinks>'
);
expect(xml).toContain('<hyperlink ref="A1" location="Справочник!B2"/>');
expect(relationshipsXml).toContain('Target="https://example.com/report"');
expect(relationshipsXml).not.toContain("Справочник!B2");
});
it("назначает drawing relationship после внешних ссылок, игнорируя внутренние ссылки XLSX", () => {
const relationshipsXml = buildWorksheetRelationshipsXml({ A1: "#Справочник!B2" }, "../drawings/drawing1.xml");
expect(relationshipsXml).toContain('Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"');
expect(relationshipsXml).not.toContain('TargetMode="External"');
});
it("собирает и читает legacy comments XLSX для заметок ячеек", () => {
const commentsXml = buildCommentsXml({ B2: "Проверить сумму", A1: "Комментарий\nв две строки" });
const parsedComments = worksheetCommentsXmlToComments({
comments: {
commentList: {
comment: [
{ ref: "B2", text: { t: "Проверить сумму" } },
{ ref: "A1", text: { r: [{ t: "Комментарий" }, { t: "\nв две строки" }] } }
]
}
}
});
expect(commentsXml).toContain('<comment ref="A1" authorId="0"><text><t>Комментарий\nв две строки</t></text></comment>');
expect(commentsXml).toContain('<comment ref="B2" authorId="0"><text><t>Проверить сумму</t></text></comment>');
expect(parsedComments).toEqual({ A1: "Комментарий\nв две строки", B2: "Проверить сумму" });
});
it("сохраняет заметки ячеек XLSX в comments, VML и relationships", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Comments.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sheet 1",
cells: { A1: "Итого" },
comments: { A1: "Проверено финансовым отделом" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const contentTypesXml = await zip.file("[Content_Types].xml")?.async("string");
const worksheetXml = await zip.file("xl/worksheets/sheet1.xml")?.async("string");
const relationshipsXml = await zip.file("xl/worksheets/_rels/sheet1.xml.rels")?.async("string");
const commentsXml = await zip.file("xl/comments1.xml")?.async("string");
const vmlXml = await zip.file("xl/drawings/vmlDrawing1.vml")?.async("string");
const imported = await importXlsxFile(new File([blob], "Comments.xlsx", { type: blob.type }));
expect(contentTypesXml).toContain("/xl/comments1.xml");
expect(contentTypesXml).toContain('Extension="vml"');
expect(worksheetXml).toContain('<legacyDrawing r:id="rId2"/>');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"');
expect(relationshipsXml).toContain('Target="../comments1.xml"');
expect(relationshipsXml).toContain('Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing"');
expect(commentsXml).toContain('<comment ref="A1" authorId="0">');
expect(vmlXml).toContain("<x:Row>0</x:Row><x:Column>0</x:Column>");
expect(imported.sheets[0].comments).toEqual({ A1: "Проверено финансовым отделом" });
});
it("сохраняет базовое форматирование ячеек XLSX в XML листа", () => {
const cellFormats = {
A1: { bold: true, fontSize: 14, fontFamily: "Arial", textColor: "0f5fae", fillColor: "fff2cc", horizontalAlign: "center" as const },
B2: { italics: true, underline: true, strike: true },
C3: { fillColor: "bad-color" }
};
const registry = createCellStyleRegistry([cellFormats]);
const xml = buildWorksheetXml({ A1: "Итог", B2: "Комментарий", C3: "Без стиля" }, [], {}, cellFormats, registry);
const emptyCellXml = buildWorksheetXml({}, [], {}, { C3: { underline: true } });
expect(xml).toContain('<c r="A1" s="1" t="inlineStr"><is><t>Итог</t></is></c>');
expect(xml).toContain('<c r="B2" s="2" t="inlineStr"><is><t>Комментарий</t></is></c>');
expect(xml).toContain('<c r="C3" t="inlineStr"><is><t>Без стиля</t></is></c>');
expect(emptyCellXml).toContain('<c r="C3" s="1"/>');
});
it("записывает зачеркивание ячейки XLSX в styles.xml", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Strike.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sheet 1",
cells: { A1: "Deleted" },
cellFormats: { A1: { strike: true } }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const stylesXml = await zip.file("xl/styles.xml")?.async("string");
expect(stylesXml).toContain("<strike/>");
});
it("записывает семейство шрифта ячейки XLSX в styles.xml", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Font.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sheet 1",
cells: { A1: "Arial" },
cellFormats: { A1: { fontFamily: "Arial" } }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const stylesXml = await zip.file("xl/styles.xml")?.async("string");
expect(stylesXml).toContain('<name val="Arial"/>');
});
it("записывает перенос текста ячейки XLSX в styles.xml", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Wrap.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sheet 1",
cells: { A1: "Long text" },
cellFormats: { A1: { wrapText: true } }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const stylesXml = await zip.file("xl/styles.xml")?.async("string");
expect(stylesXml).toContain('applyAlignment="1"');
expect(stylesXml).toContain('wrapText="1"');
});
it("записывает вертикальное выравнивание ячейки XLSX в styles.xml", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "VerticalAlign.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sheet 1",
cells: { A1: "Centered" },
cellFormats: { A1: { verticalAlign: "middle" } }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const stylesXml = await zip.file("xl/styles.xml")?.async("string");
expect(stylesXml).toContain('applyAlignment="1"');
expect(stylesXml).toContain('vertical="center"');
});
it("записывает границы ячейки XLSX в styles.xml", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Borders.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sheet 1",
cells: { A1: "Bordered" },
cellFormats: { A1: { border: true, borderColor: "C2410C" } }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const stylesXml = await zip.file("xl/styles.xml")?.async("string");
expect(stylesXml).toContain('<borders count="2">');
expect(stylesXml).toContain('applyBorder="1"');
expect(stylesXml).toContain('<left style="thin">');
expect(stylesXml).toContain('<right style="thin">');
expect(stylesXml).toContain('<top style="thin">');
expect(stylesXml).toContain('<bottom style="thin">');
expect(stylesXml).toContain('<color rgb="FFC2410C"/>');
});
it("читает базовое форматирование ячеек XLSX из styles.xml и XML листа", () => {
const styleTable = xlsxStylesXmlToCellFormatTable({
styleSheet: {
numFmts: {
numFmt: { numFmtId: "164", formatCode: "dd.mm.yyyy" }
},
fonts: {
font: [{}, { b: {}, i: {}, u: {}, strike: {}, sz: { val: "14" }, name: { val: "Arial" }, color: { rgb: "FF0F5FAE" } }]
},
fills: {
fill: [{}, {}, { patternFill: { fgColor: { rgb: "FFFFF2CC" } } }]
},
borders: {
border: [
{},
{
left: { style: "thin", color: { rgb: "FFC2410C" } },
right: { style: "thin", color: { rgb: "FFC2410C" } },
top: { style: "thin", color: { rgb: "FFC2410C" } },
bottom: { style: "thin", color: { rgb: "FFC2410C" } }
}
]
},
cellXfs: {
xf: [
{ fontId: "0", fillId: "0" },
{ fontId: "1", fillId: "2", borderId: "1", numFmtId: "164", alignment: { horizontal: "center", vertical: "center", wrapText: "1" } },
{ fontId: "0", fillId: "0", numFmtId: "14" }
]
}
}
});
expect(styleTable[1]).toEqual({
bold: true,
italics: true,
underline: true,
strike: true,
fontSize: 14,
fontFamily: "Arial",
textColor: "0F5FAE",
fillColor: "FFF2CC",
horizontalAlign: "center",
verticalAlign: "middle",
wrapText: true,
border: true,
borderColor: "C2410C",
numberFormat: "dd.mm.yyyy"
});
expect(styleTable[2]).toEqual({ numberFormat: "m/d/yy" });
expect(
worksheetXmlToCellFormats(
{
worksheet: {
sheetData: {
row: {
r: "1",
c: [{ r: "A1", s: "1", t: "inlineStr", is: { t: "Итог" } }]
}
}
}
},
styleTable
)
).toEqual({
A1: {
bold: true,
italics: true,
underline: true,
strike: true,
fontSize: 14,
fontFamily: "Arial",
textColor: "0F5FAE",
fillColor: "FFF2CC",
horizontalAlign: "center",
verticalAlign: "middle",
wrapText: true,
border: true,
borderColor: "C2410C",
numberFormat: "dd.mm.yyyy"
}
});
});
it("читает theme, tint и indexed цвета XLSX из styles.xml", () => {
const styleTable = xlsxStylesXmlToCellFormatTable({
styleSheet: {
fonts: {
font: [{ color: { theme: "1" } }, { color: { theme: "4", tint: "0.5" } }, { color: { indexed: "22" } }]
},
fills: {
fill: [{}, {}, { patternFill: { fgColor: { theme: "6" } } }]
},
borders: {
border: [{}, { left: { style: "thin", color: { indexed: "4" } } }]
},
cellXfs: {
xf: [
{ fontId: "0", fillId: "0", borderId: "0" },
{ fontId: "1", fillId: "2", borderId: "1" },
{ fontId: "2", fillId: "0", borderId: "0" }
]
}
}
});
expect(styleTable[0]).toBeUndefined();
expect(styleTable[1]).toEqual({
textColor: "A7C0DE",
fillColor: "9BBB59",
border: true,
borderColor: "0000FF"
});
expect(styleTable[2]).toEqual({ textColor: "C0C0C0" });
});
it("использует цвета из xl/theme/theme1.xml при импорте XLSX", async () => {
const themeXml = `<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:clrScheme name="Custom"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="222222"/></a:dk2><a:lt2><a:srgbClr val="EEEEEE"/></a:lt2><a:accent1><a:srgbClr val="123456"/></a:accent1></a:clrScheme></a:themeElements></a:theme>`;
const zip = new JSZip();
zip.file("xl/workbook.xml", `<workbook><sheets><sheet name="Theme" sheetId="1" r:id="rId1"/></sheets></workbook>`);
zip.file(
"xl/_rels/workbook.xml.rels",
`<Relationships><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/></Relationships>`
);
zip.file("xl/theme/theme1.xml", themeXml);
zip.file(
"xl/styles.xml",
`<styleSheet><fonts count="2"><font/><font><color theme="4"/><name val="Calibri"/></font></fonts><fills count="1"><fill><patternFill patternType="none"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0"/></cellXfs></styleSheet>`
);
zip.file(
"xl/worksheets/sheet1.xml",
`<worksheet><sheetData><row r="1"><c r="A1" s="1" t="inlineStr"><is><t>Themed</t></is></c></row></sheetData></worksheet>`
);
expect(xlsxThemeXmlToColors({ "a:theme": { "a:themeElements": { "a:clrScheme": { "a:accent1": { "a:srgbClr": { val: "123456" } } } } } })[4]).toBe(
"123456"
);
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const imported = await importXlsxFile(new File([blob], "Theme.xlsx", { type: blob.type }));
expect(imported.sheets[0].cellFormats).toEqual({
A1: { textColor: "123456" }
});
});
it("преобразует XML листа в модель QExcell", () => {
const cells = worksheetXmlToCells(
{
worksheet: {
sheetData: {
row: {
r: "1",
c: [
{ r: "A1", t: "inlineStr", is: { t: "Статья" } },
{ r: "B1", v: "1000" },
{ r: "C1", f: "B1*2" }
]
}
}
}
},
[]
);
expect(cells).toEqual({
A1: "Статья",
B1: "1000",
C1: "=B1*2"
});
});
it("читает merged cells из XML листа", () => {
expect(
worksheetXmlToMergedCells({
worksheet: {
mergeCells: {
mergeCell: [{ ref: "C2:A1" }, { ref: "D4:D4" }, { ref: "bad" }]
}
}
})
).toEqual([{ start: "A1", end: "C2" }]);
});
it("читает внешние гиперссылки XLSX из XML листа и relationships", () => {
expect(
worksheetXmlToHyperlinks(
{
worksheet: {
hyperlinks: {
hyperlink: [
{ ref: "A1", "r:id": "rIdLink" },
{ ref: "B2", "r:id": "rIdUnsafe" },
{ ref: "bad", "r:id": "rIdLink" }
]
}
}
},
{
Relationships: {
Relationship: [
{
Id: "rIdLink",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
Target: "https://example.com/report",
TargetMode: "External"
},
{
Id: "rIdUnsafe",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
Target: "javascript:alert(1)",
TargetMode: "External"
}
]
}
}
)
).toEqual({ A1: "https://example.com/report" });
});
it("читает внутренние гиперссылки XLSX из location", () => {
expect(
worksheetXmlToHyperlinks(
{
worksheet: {
hyperlinks: {
hyperlink: [
{ ref: "A1", location: "Справочник!B2" },
{ ref: "B2", location: "" },
{ ref: "bad", location: "Справочник!B2" }
]
}
}
},
undefined
)
).toEqual({ A1: "#Справочник!B2" });
});
it("читает ссылки листов и relationships из workbook XML", () => {
expect(
workbookSheetRefs({
workbook: {
sheets: {
sheet: { name: "Бюджет", sheetId: "7", "r:id": "rId3" }
}
}
})
).toEqual([{ id: "sheet-7", name: "Бюджет", relationshipId: "rId3" }]);
expect(
workbookSheetRefs({
workbook: {
sheets: {
sheet: { name: "Архив", sheetId: "8", "r:id": "rId4", state: "hidden" }
}
}
})
).toEqual([{ id: "sheet-8", name: "Архив", visibility: "hidden", relationshipId: "rId4" }]);
expect(
workbookRelationshipTargets({
Relationships: {
Relationship: [{ Id: "rId3", Target: "worksheets/sheet7.xml" }]
}
})
).toEqual({ rId3: "worksheets/sheet7.xml" });
expect(
workbookActiveSheetId(
{
workbook: {
bookViews: { workbookView: { activeTab: "1" } }
}
},
[
{ id: "sheet-1" },
{ id: "sheet-7" }
]
)
).toBe("sheet-7");
});
it("detects the XLSX 1904 date system flag from workbook properties", () => {
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "1" } } })).toBe(true);
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "true" } } })).toBe(true);
expect(workbookUsesDate1904({ workbook: { workbookPr: { date1904: "0" } } })).toBe(false);
});
it("converts imported XLSX 1904 date serials to the QExcell 1900 serial system", async () => {
const zip = new JSZip();
zip.file(
"xl/workbook.xml",
`<workbook><workbookPr date1904="1"/><sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`
);
zip.file(
"xl/_rels/workbook.xml.rels",
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`
);
zip.file("xl/styles.xml", `<styleSheet><cellXfs count="3"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="20"/></cellXfs></styleSheet>`);
zip.file(
"xl/worksheets/sheet1.xml",
`<worksheet><sheetData><row r="1"><c r="A1" s="1"><v>43465</v></c><c r="B1"><v>5</v></c><c r="C1" s="2"><v>0.5</v></c></row></sheetData></worksheet>`
);
const blob = await zip.generateAsync({
type: "blob",
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const imported = await importXlsxFile(new File([blob], "Date1904.xlsx", { type: blob.type }));
expect(imported.sheets[0].cells).toEqual({ A1: "44927", B1: "5", C1: "0.5" });
expect(imported.sheets[0].cellFormats).toEqual({ A1: { numberFormat: "m/d/yy" }, C1: { numberFormat: "h:mm" } });
});
it("сохраняет и импортирует активный лист XLSX через workbookView activeTab", async () => {
const blob = await exportXlsxBlob({
id: "book-1",
title: "Активный лист.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-2",
sheets: [
{ id: "sheet-1", name: "Первый", cells: { A1: "Первый" } },
{ id: "sheet-2", name: "Второй", cells: { A1: "Второй" } }
]
});
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], "Активный лист.xlsx", { type: blob.type }));
expect(workbookXml).toContain('<workbookView activeTab="1"/>');
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(
{
workbook: {
definedNames: {
definedName: [
{ name: "_xlnm.Print_Area", localSheetId: "0", "#text": "'Отчет'!$D$20:$A$1" },
{ name: "_xlnm.Print_Area", localSheetId: "1", "#text": "'Архив'!$B$2:$C$4,'Архив'!$E$1:$E$3" }
]
}
}
},
[
{ id: "sheet-1", name: "Отчет" },
{ id: "sheet-2", name: "Архив" }
]
)
).toEqual({ "sheet-1": "A1:D20", "sheet-2": "B2:C4,E1:E3" });
const blob = await exportXlsxBlob({
id: "book-1",
title: "Область печати.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{ id: "sheet-1", name: "Отчет", printArea: "D20:A1", cells: { A1: "Начало", D20: "Итог" } },
{ id: "sheet-2", name: "Архив", printArea: "B2:C4,E1:E3", cells: { B2: "Архив" } }
]
});
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], "Область печати.xlsx", { type: blob.type }));
expect(workbookXml).toContain('<definedNames><definedName name="_xlnm.Print_Area" localSheetId="0">\'Отчет\'!$A$1:$D$20</definedName>');
expect(workbookXml).toContain('<definedName name="_xlnm.Print_Area" localSheetId="1">\'Архив\'!$B$2:$C$4,\'Архив\'!$E$1:$E$3</definedName></definedNames>');
expect(imported.sheets[0].printArea).toBe("A1:D20");
expect(imported.sheets[1].printArea).toBe("B2:C4,E1:E3");
});
it("сохраняет и импортирует скрытые листы XLSX через sheet state", async () => {
const blob = await exportXlsxBlob({
id: "book-1",
title: "Скрытые листы.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{ id: "sheet-1", name: "Рабочий", cells: { A1: "Открыт" } },
{ id: "sheet-2", name: "Архив", visibility: "hidden", cells: { A1: "Скрыт" } },
{ id: "sheet-3", name: "Служебный", visibility: "veryHidden", cells: { A1: "Очень скрыт" } }
]
});
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], "Скрытые листы.xlsx", { type: blob.type }));
expect(workbookXml).toContain('<sheet name="Архив" sheetId="2" r:id="rId2" state="hidden"/>');
expect(workbookXml).toContain('<sheet name="Служебный" sheetId="3" r:id="rId3" state="veryHidden"/>');
expect(imported.sheets[1]).toMatchObject({ id: "sheet-2", name: "Архив", visibility: "hidden" });
expect(imported.sheets[2]).toMatchObject({ id: "sheet-3", name: "Служебный", visibility: "veryHidden" });
});
it("читает и записывает закрепленные области XLSX через sheetViews pane", async () => {
expect(
worksheetXmlToFrozenPane({
worksheet: {
sheetViews: {
sheetView: {
pane: { xSplit: "1", ySplit: "2", topLeftCell: "B3", activePane: "bottomRight", state: "frozen" }
}
}
}
})
).toEqual({ columns: 1, rows: 2, topLeftCell: "B3", activePane: "bottomRight" });
expect(
worksheetXmlToFrozenPane({
worksheet: {
sheetViews: {
sheetView: {
pane: { xSplit: "1", state: "split" }
}
}
}
})
).toBeUndefined();
expect(buildSheetViewsXml({ columns: 1, rows: 1 })).toBe(
'<sheetViews><sheetView workbookViewId="0"><pane xSplit="1" ySplit="1" topLeftCell="B2" activePane="bottomRight" state="frozen"/></sheetView></sheetViews>'
);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Frozen panes.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Freeze",
frozenPane: { columns: 1, rows: 1 },
cells: { A1: "Header", B2: "Body" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "Frozen panes.xlsx", { type: blob.type }));
expect(sheetXml).toContain(
'<sheetViews><sheetView workbookViewId="0"><pane xSplit="1" ySplit="1" topLeftCell="B2" activePane="bottomRight" state="frozen"/></sheetView></sheetViews>'
);
expect(sheetXml.indexOf("<sheetViews>")).toBeLessThan(sheetXml.indexOf("<sheetData>"));
expect(imported.sheets[0].frozenPane).toEqual({ columns: 1, rows: 1, topLeftCell: "B2", activePane: "bottomRight" });
});
it("читает и записывает скрытие сетки листа XLSX через sheetView showGridLines", async () => {
expect(
worksheetXmlToShowGridLines({
worksheet: {
sheetViews: {
sheetView: { showGridLines: "0" }
}
}
})
).toBe(false);
expect(buildSheetViewsXml(undefined, false)).toBe('<sheetViews><sheetView workbookViewId="0" showGridLines="0"/></sheetViews>');
expect(buildSheetViewsXml({ rows: 1 }, false)).toBe(
'<sheetViews><sheetView workbookViewId="0" showGridLines="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Grid lines.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Grid",
showGridLines: false,
cells: { A1: "No grid" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "Grid lines.xlsx", { type: blob.type }));
expect(sheetXml).toContain('<sheetViews><sheetView workbookViewId="0" showGridLines="0"/></sheetViews>');
expect(imported.sheets[0].showGridLines).toBe(false);
});
it("читает и записывает масштаб листа XLSX через sheetView zoomScale", async () => {
expect(
worksheetXmlToZoomScale({
worksheet: {
sheetViews: {
sheetView: { zoomScale: "125" }
}
}
})
).toBe(125);
expect(
worksheetXmlToZoomScale({
worksheet: {
sheetViews: {
sheetView: { zoomScale: "100" }
}
}
})
).toBe(0);
expect(buildSheetViewsXml(undefined, undefined, 125)).toBe('<sheetViews><sheetView workbookViewId="0" zoomScale="125"/></sheetViews>');
expect(buildSheetViewsXml({ columns: 1 }, false, 90)).toBe(
'<sheetViews><sheetView workbookViewId="0" showGridLines="0" zoomScale="90"><pane xSplit="1" topLeftCell="B1" activePane="topRight" state="frozen"/></sheetView></sheetViews>'
);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Zoom.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Zoom",
zoomScale: 125,
cells: { A1: "Zoomed" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "Zoom.xlsx", { type: blob.type }));
expect(sheetXml).toContain('<sheetViews><sheetView workbookViewId="0" zoomScale="125"/></sheetViews>');
expect(imported.sheets[0].zoomScale).toBe(125);
});
it("читает и записывает автофильтр XLSX через autoFilter ref", async () => {
expect(
worksheetXmlToAutoFilter({
worksheet: {
autoFilter: { ref: "$D$20:$A$1" }
}
})
).toBe("A1:D20");
expect(buildAutoFilterXml("B2:B2")).toBe('<autoFilter ref="B2"/>');
expect(buildAutoFilterXml("bad")).toBe("");
const blob = await exportXlsxBlob({
id: "book-1",
title: "AutoFilter.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Filter",
autoFilter: "D20:A1",
mergedCells: [{ start: "A22", end: "D22" }],
cells: { A1: "Product", D20: "Total" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "AutoFilter.xlsx", { type: blob.type }));
expect(sheetXml).toContain('<autoFilter ref="A1:D20"/>');
expect(sheetXml.indexOf("<sheetData>")).toBeLessThan(sheetXml.indexOf("<autoFilter"));
expect(sheetXml.indexOf("<autoFilter")).toBeLessThan(sheetXml.indexOf("<mergeCells"));
expect(imported.sheets[0].autoFilter).toBe("A1:D20");
});
it("читает и записывает цвет вкладки листа XLSX через sheetPr tabColor", async () => {
expect(
worksheetXmlToTabColor({
worksheet: {
sheetPr: { tabColor: { rgb: "FF0F766E" } }
}
})
).toBe("0F766E");
expect(
worksheetXmlToTabColor(
{
worksheet: {
sheetPr: { tabColor: { theme: "4" } }
}
},
["000000", "FFFFFF", "1F497D", "EEECE1", "123456"]
)
).toBe("123456");
expect(buildSheetPrXml("#0f766e")).toBe('<sheetPr><tabColor rgb="FF0F766E"/></sheetPr>');
expect(buildSheetPrXml("bad")).toBe("");
const blob = await exportXlsxBlob({
id: "book-1",
title: "Tab color.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Color",
tabColor: "#0f766e",
frozenPane: { rows: 1 },
cells: { A1: "Header" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "Tab color.xlsx", { type: blob.type }));
expect(sheetXml).toContain('<sheetPr><tabColor rgb="FF0F766E"/></sheetPr>');
expect(sheetXml.indexOf("<sheetPr>")).toBeLessThan(sheetXml.indexOf("<sheetViews>"));
expect(imported.sheets[0].tabColor).toBe("0F766E");
});
it("читает и записывает размеры колонок и строк XLSX", async () => {
expect(
worksheetXmlToColumnWidths({
worksheet: {
cols: {
col: [
{ min: "1", max: "2", width: "18.5" },
{ min: "4", max: "4", width: "9.25" }
]
}
}
})
).toEqual({ A: 18.5, B: 18.5, D: 9.25 });
expect(
worksheetXmlToRowHeights({
worksheet: {
sheetData: {
row: [{ r: "1", ht: "24" }, { r: "4", ht: "30.5" }]
}
}
})
).toEqual({ "1": 24, "4": 30.5 });
expect(buildColumnWidthsXml({ C: 9.25, A: 18.5, "1bad": 1 })).toBe(
'<cols><col min="1" max="1" width="18.5" customWidth="1"/><col min="3" max="3" width="9.25" customWidth="1"/></cols>'
);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Sheet sizes.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Sizes",
columnWidths: { A: 18.5, C: 9.25 },
rowHeights: { "1": 24, "4": 30.5 },
cells: { A1: "Header", C2: "Value" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "Sheet sizes.xlsx", { type: blob.type }));
expect(sheetXml).toContain(
'<cols><col min="1" max="1" width="18.5" customWidth="1"/><col min="3" max="3" width="9.25" customWidth="1"/></cols>'
);
expect(sheetXml).toContain('<row r="1" ht="24" customHeight="1"><c r="A1" t="inlineStr"><is><t>Header</t></is></c></row>');
expect(sheetXml).toContain('<row r="4" ht="30.5" customHeight="1"/>');
expect(sheetXml.indexOf("<cols>")).toBeLessThan(sheetXml.indexOf("<sheetData>"));
expect(imported.sheets[0].columnWidths).toEqual({ A: 18.5, C: 9.25 });
expect(imported.sheets[0].rowHeights).toEqual({ "1": 24, "4": 30.5 });
});
it("читает и записывает скрытые колонки и строки XLSX", async () => {
expect(
worksheetXmlToHiddenColumns({
worksheet: {
cols: {
col: [
{ min: "2", max: "3", hidden: "1" },
{ min: "5", max: "5", width: "12", hidden: "true" }
]
}
}
})
).toEqual({ B: true, C: true, E: true });
expect(
worksheetXmlToHiddenRows({
worksheet: {
sheetData: {
row: [{ r: "4", hidden: "1" }, { r: "6", ht: "21", hidden: "true" }]
}
}
})
).toEqual({ "4": true, "6": true });
expect(buildColumnWidthsXml({ E: 12 }, { B: true, C: true, E: true })).toBe(
'<cols><col min="2" max="2" hidden="1"/><col min="3" max="3" hidden="1"/><col min="5" max="5" width="12" customWidth="1" hidden="1"/></cols>'
);
const blob = await exportXlsxBlob({
id: "book-1",
title: "Hidden rows and columns.xlsx",
updatedAt: "2026-06-01T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Hidden",
columnWidths: { E: 12 },
rowHeights: { "6": 21 },
hiddenColumns: { B: true, C: true, E: true },
hiddenRows: { "4": true, "6": true },
cells: { A1: "Visible" }
}
]
});
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const sheetXml = (await zip.file("xl/worksheets/sheet1.xml")?.async("string")) ?? "";
const imported = await importXlsxFile(new File([blob], "Hidden rows and columns.xlsx", { type: blob.type }));
expect(sheetXml).toContain(
'<cols><col min="2" max="2" hidden="1"/><col min="3" max="3" hidden="1"/><col min="5" max="5" width="12" customWidth="1" hidden="1"/></cols>'
);
expect(sheetXml).toContain('<row r="4" hidden="1"/>');
expect(sheetXml).toContain('<row r="6" ht="21" customHeight="1" hidden="1"/>');
expect(imported.sheets[0].hiddenColumns).toEqual({ B: true, C: true, E: true });
expect(imported.sheets[0].hiddenRows).toEqual({ "4": true, "6": true });
});
it("создает OOXML chart part для диаграммы XLSX", () => {
const xml = buildChartXml(
{
id: "chart-1",
type: "bar",
title: "Итого",
labelRange: "A2:A4",
valueRange: "E2:E4"
},
"Бюджет",
{
A2: "Базовый",
A3: "Команды",
A4: "Корпоративный",
E2: "43600",
E3: "27200",
E4: "64800"
}
);
expect(xml).toContain("<c:barChart>");
expect(xml).toContain("<c:f>'Бюджет'!$A$2:$A$4</c:f>");
expect(xml).toContain("<c:f>'Бюджет'!$E$2:$E$4</c:f>");
expect(xml).toContain("<c:v>Базовый</c:v>");
expect(xml).toContain("<c:v>43600</c:v>");
});
it("читает базовую диаграмму из chart XML", () => {
expect(
chartXmlToSheetChart(
{
"c:chartSpace": {
"c:chart": {
"c:title": {
"c:tx": {
"c:rich": {
"a:p": { "a:r": { "a:t": "Продажи" } }
}
}
},
"c:plotArea": {
"c:lineChart": {
"c:ser": {
"c:cat": { "c:strRef": { "c:f": "'Бюджет'!$A$2:$A$4" } },
"c:val": { "c:numRef": { "c:f": "'Бюджет'!$E$2:$E$4" } }
}
}
}
}
}
},
"Бюджет",
0
)
).toEqual({
id: "xlsx-chart-1",
type: "line",
title: "Продажи",
labelRange: "A2:A4",
valueRange: "E2:E4"
});
});
it("готовит точки диаграммы из диапазонов листа", () => {
expect(
chartDataPoints(
{
id: "chart-1",
type: "bar",
title: "Итого",
labelRange: "A2:A3",
valueRange: "E2:E3"
},
{ A2: "Базовый", A3: "Команды", E2: "=SUM(B2:D2)", B2: "10", C2: "20", D2: "30", E3: "25" }
)
).toEqual([
{ label: "Базовый", value: 60 },
{ label: "Команды", value: 25 }
]);
});
it("сохраняет и импортирует несколько листов с дальними ячейками", async () => {
const blob = await exportXlsxBlob({
id: "workbook",
title: "Бюджет.xlsx",
updatedAt: "2026-05-31T00:00:00.000Z",
activeSheet: "sheet-1",
sheets: [
{
id: "sheet-1",
name: "Бюджет",
cells: {
A1: "Статья",
AA20: "7",
AB20: "8",
AC20: "=SUM(AA20:AB20)",
D20: "44927",
E20: "0.25"
},
mergedCells: [{ start: "A1", end: "C1" }],
hyperlinks: { A1: "https://example.com/budget" },
cellFormats: {
A1: { bold: true, strike: true, fontSize: 14, fontFamily: "Arial", textColor: "0F5FAE", fillColor: "FFF2CC", horizontalAlign: "center", verticalAlign: "middle", wrapText: true, border: true, borderColor: "C2410C" },
AC20: { underline: true },
D20: { numberFormat: "dd.mm.yyyy" },
E20: { numberFormat: "0%" }
},
charts: [
{
id: "chart-budget",
type: "bar",
title: "Итого",
labelRange: "A2:A3",
valueRange: "AA20:AB20"
}
]
},
{
id: "sheet-2",
name: "Справочник",
cells: {
B2: "Проект"
}
}
]
});
const imported = await importXlsxFile(new File([blob], "Бюджет.xlsx", { type: blob.type }));
expect(imported.title).toBe("Бюджет.xlsx");
expect(imported.sheets).toHaveLength(2);
expect(imported.sheets[0].name).toBe("Бюджет");
expect(imported.sheets[0].cells.AC20).toBe("=SUM(AA20:AB20)");
expect(imported.sheets[0].mergedCells).toEqual([{ start: "A1", end: "C1" }]);
expect(imported.sheets[0].hyperlinks).toEqual({ A1: "https://example.com/budget" });
expect(imported.sheets[0].cellFormats).toEqual({
A1: { bold: true, strike: true, fontSize: 14, fontFamily: "Arial", textColor: "0F5FAE", fillColor: "FFF2CC", horizontalAlign: "center", verticalAlign: "middle", wrapText: true, border: true, borderColor: "C2410C" },
AC20: { underline: true },
D20: { numberFormat: "dd.mm.yyyy" },
E20: { numberFormat: "0%" }
});
expect(imported.sheets[0].charts).toEqual([
{
id: "xlsx-chart-1",
type: "bar",
title: "Итого",
labelRange: "A2:A3",
valueRange: "AA20:AB20"
}
]);
expect(imported.sheets[1].name).toBe("Справочник");
expect(imported.sheets[1].cells.B2).toBe("Проект");
});
});