1247 lines
47 KiB
TypeScript
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("Проект");
|
|
});
|
|
});
|