fix(export): split XLSX sheets and stream worksheet XML

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-31 19:45:37 +08:00
1 parent ecac4800c4
commit c43e8455c4
16 files changed
+1006 -250

No files matched your search

+61 -19
View File
@@ -11,6 +11,13 @@ export interface XlsxWorksheetData {
numericColumnRightAlign?: boolean;
}
interface XlsxWorksheetSegment {
worksheet: XlsxWorksheetData;
sheetName?: string;
rowStart: number;
rowEnd: number;
}
type ZipEntry = {
path: string;
data: Uint8Array;
@@ -20,6 +27,7 @@ type ZipEntry = {
const encoder = new TextEncoder();
const CRC_TABLE = buildCrcTable();
const XLSX_MAX_DATA_ROWS = 1_048_575;
function buildCrcTable(): number[] {
const table: number[] = [];
@@ -84,15 +92,20 @@ function normalizeSheetName(value?: string): string {
return name.slice(0, 31);
}
function normalizeUniqueSheetNames(sheets: readonly XlsxWorksheetData[]): string[] {
function appendSheetSuffix(base: string, suffix: number): string {
const suffixText = ` (${suffix})`;
const maxBaseLength = Math.max(0, 31 - [...suffixText].length);
return `${[...base].slice(0, maxBaseLength).join("")}${suffixText}`;
}
function normalizeUniqueSheetNames(sheets: readonly { sheetName?: string }[]): string[] {
const names: string[] = [];
sheets.forEach((sheet, index) => {
const base = normalizeSheetName(sheet.sheetName || `Sheet${index + 1}`);
let candidate = base;
let suffix = 2;
while (names.includes(candidate)) {
const suffixText = ` (${suffix})`;
candidate = `${base.slice(0, 31 - suffixText.length)}${suffixText}`;
candidate = appendSheetSuffix(base, suffix);
suffix += 1;
}
names.push(candidate);
@@ -100,10 +113,10 @@ function normalizeUniqueSheetNames(sheets: readonly XlsxWorksheetData[]): string
return names;
}
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[], columnComments?: readonly (string | null)[]): number[] {
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[], rowStart: number, rowEnd: number, columnComments?: readonly (string | null)[]): number[] {
return columns.map((column, colIndex) => {
const headerText = columnComments?.[colIndex] || column;
const values = rows.slice(0, 100).map((row) => row[colIndex]);
const values = Array.from({ length: Math.min(100, rowEnd - rowStart) }, (_, index) => rows[rowStart + index]?.[colIndex]);
const maxLen = [headerText, ...values.map((value) => (value == null ? "" : String(value)))].map((value) => Math.min(value.length, 60)).reduce((max, length) => Math.max(max, length), 8);
return Math.max(10, Math.min(60, maxLen + 2));
});
@@ -143,22 +156,22 @@ function cellXml(value: XlsxCellValue, rowIndex: number, colIndex: number, style
return `<c r="${ref}" t="inlineStr"${styleAttr}><is><t>${escapeXml(String(value))}</t></is></c>`;
}
function worksheetXml(data: XlsxWorksheetData): string {
function worksheetXml(segment: XlsxWorksheetSegment): string {
const data = segment.worksheet;
const columns = data.columns;
const rows = data.rows;
const totalRows = rows.length + 1;
const totalRows = segment.rowEnd - segment.rowStart + 1;
const range = sheetRange(columns.length, totalRows);
const widths = estimateColumnWidths(columns, rows, data.columnComments);
const widths = estimateColumnWidths(columns, rows, segment.rowStart, segment.rowEnd, data.columnComments);
const rightAlignEnabled = data.numericColumnRightAlign !== false;
const colsXml = widths.map((width, index) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`).join("");
const headerXml = `<row r="1">${columns.map((column, index) => cellXml(data.columnComments?.[index] || column, 0, index, 1)).join("")}</row>`;
const bodyXml = rows
.map((row, rowIndex) => {
const excelRowIndex = rowIndex + 2;
const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex, numericColumnStyle(data.columnTypes?.[colIndex], rightAlignEnabled), data.columnTypes?.[colIndex])).join("");
return `<row r="${excelRowIndex}">${cells}</row>`;
})
.join("");
const bodyXml = Array.from({ length: segment.rowEnd - segment.rowStart }, (_, rowIndex) => {
const row = rows[segment.rowStart + rowIndex]!;
const excelRowIndex = rowIndex + 2;
const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex, numericColumnStyle(data.columnTypes?.[colIndex], rightAlignEnabled), data.columnTypes?.[colIndex])).join("");
return `<row r="${excelRowIndex}">${cells}</row>`;
}).join("");
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
@@ -171,6 +184,30 @@ function worksheetXml(data: XlsxWorksheetData): string {
</worksheet>`;
}
function splitWorksheetsForMaxRows(sheets: readonly XlsxWorksheetData[], maxDataRowsPerSheet: number): XlsxWorksheetSegment[] {
const maxRows = Number.isFinite(maxDataRowsPerSheet) ? Math.max(1, Math.floor(maxDataRowsPerSheet)) : XLSX_MAX_DATA_ROWS;
const segments: XlsxWorksheetSegment[] = [];
for (const worksheet of sheets) {
if (worksheet.rows.length <= maxRows) {
segments.push({ worksheet, sheetName: worksheet.sheetName, rowStart: 0, rowEnd: worksheet.rows.length });
continue;
}
const baseName = normalizeSheetName(worksheet.sheetName);
for (let rowStart = 0, chunkIndex = 0; rowStart < worksheet.rows.length; rowStart += maxRows, chunkIndex += 1) {
segments.push({
worksheet,
sheetName: chunkIndex === 0 ? baseName : appendSheetSuffix(baseName, chunkIndex + 1),
rowStart,
rowEnd: Math.min(rowStart + maxRows, worksheet.rows.length),
});
}
}
return segments;
}
function contentTypesXml(sheetCount = 1): string {
const worksheetOverrides = Array.from({ length: sheetCount }, (_, index) => ` <Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -278,14 +315,19 @@ export function buildXlsxWorkbook(data: XlsxWorksheetData): Uint8Array {
}
export function buildXlsxWorkbookMulti(sheets: readonly XlsxWorksheetData[]): Uint8Array {
return buildXlsxWorkbookMultiWithMaxRows(sheets, XLSX_MAX_DATA_ROWS);
}
export function buildXlsxWorkbookMultiWithMaxRows(sheets: readonly XlsxWorksheetData[], maxDataRowsPerSheet: number): Uint8Array {
if (sheets.length === 0) throw new Error("At least one worksheet is required");
const sheetNames = normalizeUniqueSheetNames(sheets);
const segments = splitWorksheetsForMaxRows(sheets, maxDataRowsPerSheet);
const sheetNames = normalizeUniqueSheetNames(segments);
return createZip([
{ path: "[Content_Types].xml", content: contentTypesXml(sheets.length) },
{ path: "[Content_Types].xml", content: contentTypesXml(segments.length) },
{ path: "_rels/.rels", content: rootRelsXml() },
{ path: "xl/workbook.xml", content: workbookXml(sheetNames) },
{ path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml(sheets.length) },
{ path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml(segments.length) },
{ path: "xl/styles.xml", content: stylesXml() },
...sheets.map((sheet, index) => ({ path: `xl/worksheets/sheet${index + 1}.xml`, content: worksheetXml(sheet) })),
...segments.map((segment, index) => ({ path: `xl/worksheets/sheet${index + 1}.xml`, content: worksheetXml(segment) })),
]);
}