Preserve QExcell auto filters in XLSX

This commit is contained in:
Курнат Андрей
2026-06-01 12:53:19 +03:00
parent a1ad101729
commit 5ebc9639dc
4 changed files with 83 additions and 5 deletions
+38
View File
@@ -3,6 +3,7 @@ import JSZip from "jszip";
import {
buildChartXml,
buildCellXml,
buildAutoFilterXml,
buildHyperlinksXml,
buildMergeCellsXml,
buildSheetViewsXml,
@@ -16,6 +17,7 @@ import {
workbookActiveSheetId,
workbookRelationshipTargets,
workbookSheetRefs,
worksheetXmlToAutoFilter,
worksheetXmlToCells,
worksheetXmlToCellFormats,
worksheetXmlToFrozenPane,
@@ -643,6 +645,42 @@ describe("excellOffice OOXML helpers", () => {
expect(imported.sheets[0].frozenPane).toEqual({ columns: 1, rows: 1, topLeftCell: "B2", activePane: "bottomRight" });
});
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("создает OOXML chart part для диаграммы XLSX", () => {
const xml = buildChartXml(
{
+42 -3
View File
@@ -10,6 +10,7 @@ export interface ImportedExcellWorkbook {
name: string;
visibility?: "hidden" | "veryHidden";
frozenPane?: SheetFrozenPane;
autoFilter?: string;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
@@ -58,6 +59,7 @@ interface ParsedSheet {
name: string;
visibility?: "hidden" | "veryHidden";
frozenPane?: SheetFrozenPane;
autoFilter?: string;
cells: Record<string, string>;
mergedCells?: MergedCellRange[];
hyperlinks?: Record<string, string>;
@@ -254,7 +256,16 @@ export async function exportXlsxBlob(workbook: Workbook): Promise<Blob> {
const drawingRelationshipId = sheetChartParts.length > 0 ? `rId${sortedExternalHyperlinkEntries(sheet.hyperlinks).length + 1}` : "";
zip.file(
`xl/worksheets/sheet${index + 1}.xml`,
buildWorksheetXml(sheet.cells, sheet.mergedCells, sheet.hyperlinks, sheet.cellFormats, styleRegistry, drawingRelationshipId, sheet.frozenPane)
buildWorksheetXml(
sheet.cells,
sheet.mergedCells,
sheet.hyperlinks,
sheet.cellFormats,
styleRegistry,
drawingRelationshipId,
sheet.frozenPane,
sheet.autoFilter
)
);
const relationshipsXml = buildWorksheetRelationshipsXml(
sheet.hyperlinks,
@@ -286,12 +297,14 @@ export function parsedWorksheetToSheet(
styleTable: Array<CellFormat | undefined> = []
): ParsedSheet {
const frozenPane = worksheetXmlToFrozenPane(worksheetXml);
const autoFilter = worksheetXmlToAutoFilter(worksheetXml);
return {
id: sheet.id || `sheet-${index + 1}`,
name: sheet.name || `Лист ${index + 1}`,
...(sheet.visibility ? { visibility: sheet.visibility } : {}),
...(frozenPane ? { frozenPane } : {}),
...(autoFilter ? { autoFilter } : {}),
cells: worksheetXmlToCells(worksheetXml, sharedStrings),
mergedCells: worksheetXmlToMergedCells(worksheetXml),
hyperlinks: worksheetXmlToHyperlinks(worksheetXml, relationshipsXml),
@@ -410,6 +423,12 @@ export function worksheetXmlToFrozenPane(worksheetXml: unknown): SheetFrozenPane
};
}
export function worksheetXmlToAutoFilter(worksheetXml: unknown) {
const worksheet = childRecord(worksheetXml, "worksheet");
const autoFilter = childRecord(worksheet, "autoFilter");
return normalizeCellRange(String(autoFilter.ref ?? ""));
}
async function worksheetXmlToCharts(
zip: ZipInstance,
parser: InstanceType<XmlParserConstructor>,
@@ -581,7 +600,8 @@ export function buildWorksheetXml(
cellFormats: Record<string, CellFormat> = {},
styleRegistry = createCellStyleRegistry([cellFormats]),
drawingRelationshipId = "",
frozenPane?: SheetFrozenPane
frozenPane?: SheetFrozenPane,
autoFilter?: string
) {
const rows = sortedWorksheetEntries(cells, cellFormats).reduce<Record<number, Array<[string, string]>>>((grouped, entry) => {
const rowNumber = cellRowNumber(entry[0]);
@@ -600,12 +620,13 @@ export function buildWorksheetXml(
.join("");
const mergeCellsXml = buildMergeCellsXml(mergedCells);
const autoFilterXml = buildAutoFilterXml(autoFilter);
const hyperlinksXml = buildHyperlinksXml(hyperlinks);
const drawingXml = drawingRelationshipId ? `<drawing r:id="${escapeXmlAttribute(drawingRelationshipId)}"/>` : "";
const sheetViewsXml = buildSheetViewsXml(frozenPane);
return xmlDeclaration(
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetViewsXml}<sheetData>${rowXml}</sheetData>${mergeCellsXml}${hyperlinksXml}${drawingXml}</worksheet>`
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${sheetViewsXml}<sheetData>${rowXml}</sheetData>${autoFilterXml}${mergeCellsXml}${hyperlinksXml}${drawingXml}</worksheet>`
);
}
@@ -681,6 +702,11 @@ export function buildSheetViewsXml(frozenPane?: SheetFrozenPane) {
return `<sheetViews><sheetView workbookViewId="0"><pane ${attributes}/></sheetView></sheetViews>`;
}
export function buildAutoFilterXml(autoFilter?: string) {
const ref = normalizeCellRange(autoFilter ?? "");
return ref ? `<autoFilter ref="${escapeXmlAttribute(ref)}"/>` : "";
}
export function buildWorksheetRelationshipsXml(hyperlinks: Record<string, string> = {}, drawingTarget = "") {
const refs = sortedExternalHyperlinkEntries(hyperlinks);
if (refs.length === 0 && !drawingTarget) {
@@ -1633,6 +1659,19 @@ function normalizeChartRange(range: string) {
return normalized ? `${normalized.start}:${normalized.end}` : `${normalizedStart}:${normalizedStart}`;
}
function normalizeCellRange(range: string) {
const raw = range.trim().replace(/\$/g, "").toUpperCase();
const [start, end = start] = raw.split(":");
const normalizedStart = normalizeCellAddress(start);
const normalizedEnd = normalizeCellAddress(end);
if (!normalizedStart || !normalizedEnd) {
return "";
}
const normalized = normalizeMergeRange({ start: normalizedStart, end: normalizedEnd });
return normalized ? `${normalized.start}:${normalized.end}` : normalizedStart;
}
function uniqueNumbers(values: number[]) {
return [...new Set(values)].sort((first, second) => first - second);
}