Skip to content

Commit 3a887ae

Browse files
committed
feat: export streaming
1 parent ee9b449 commit 3a887ae

File tree

9 files changed

+345
-1
lines changed

9 files changed

+345
-1
lines changed

packages/demo/src/app-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Example11 from './examples/example11.js';
1212
import Example12 from './examples/example12.js';
1313
import Example13 from './examples/example13.js';
1414
import Example14 from './examples/example14.js';
15+
import Example15 from './examples/example15.js';
1516
import GettingStarted from './getting-started.js';
1617

1718
export const navbarRouting = [
@@ -42,6 +43,7 @@ export const exampleRouting = [
4243
{ name: 'example12', view: '/src/examples/example12.html', viewModel: Example12, title: '12- Worksheet Headers/Footers' },
4344
{ name: 'example13', view: '/src/examples/example13.html', viewModel: Example13, title: '13- Pictures with 2 anchors' },
4445
{ name: 'example14', view: '/src/examples/example14.html', viewModel: Example14, title: '14- Pictures with different anchors' },
46+
{ name: 'example15', view: '/src/examples/example15.html', viewModel: Example15, title: '15- Streaming Excel Export' },
4547
],
4648
},
4749
];
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<div class="example15">
2+
<div class="row">
3+
<div class="col-md-12 title-desc">
4+
<h2 class="bd-title">Example 15: Streaming Excel Export (100,000 rows)</h2>
5+
<div class="demo-subtitle">
6+
This example demonstrates streaming export for large datasets using <code>createExcelFileStream</code>. Progress is shown below.
7+
</div>
8+
</div>
9+
</div>
10+
<div class="mb-2">
11+
<button id="export" class="btn btn-success btn-sm">
12+
<i class="fa fa-download"></i>
13+
Stream Excel Export
14+
</button>
15+
<div id="progress" style="margin-top:10px; font-weight:bold;"></div>
16+
</div>
17+
</div>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createWorkbook, createExcelFileStream } from 'excel-builder-vanilla';
2+
3+
export default class Example {
4+
exportBtnElm!: HTMLButtonElement;
5+
progressElm!: HTMLDivElement;
6+
7+
mount() {
8+
this.exportBtnElm = document.querySelector('#export') as HTMLButtonElement;
9+
this.progressElm = document.querySelector('#progress') as HTMLDivElement;
10+
this.exportBtnElm.addEventListener('click', this.startProcess.bind(this));
11+
}
12+
13+
unmount() {
14+
this.exportBtnElm.removeEventListener('click', this.startProcess.bind(this));
15+
}
16+
17+
async startProcess() {
18+
const ROWS = 100_000;
19+
const originalData = [['Artist', 'Album', 'Price']];
20+
for (let i = 0; i < ROWS; i++) {
21+
originalData.push([`Artist ${i}`, `Album ${i}`, Math.round(Math.random() * 10000) / 100]);
22+
}
23+
24+
const artistWorkbook = createWorkbook();
25+
const albumList = artistWorkbook.createWorksheet({ name: 'Artists' });
26+
albumList.setData(originalData);
27+
artistWorkbook.addWorksheet(albumList);
28+
29+
// Streaming export
30+
const stream = createExcelFileStream(artistWorkbook, { chunkSize: 1000 });
31+
const chunks: Uint8Array[] = [];
32+
let processed = 0;
33+
34+
for await (const chunk of stream) {
35+
chunks.push(chunk);
36+
processed += 1000;
37+
console.log(`Exported ${Math.min(processed, ROWS)} / ${ROWS} rows...`);
38+
this.progressElm.textContent = `Exported ${Math.min(processed, ROWS)} / ${ROWS} rows...`;
39+
}
40+
41+
// Combine chunks and trigger download
42+
const blob = new Blob(chunks, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
43+
const url = URL.createObjectURL(blob);
44+
const a = document.createElement('a');
45+
a.href = url;
46+
a.download = 'LargeArtistWB.xlsx';
47+
a.click();
48+
URL.revokeObjectURL(url);
49+
this.progressElm.textContent = 'Export complete!';
50+
}
51+
}

packages/excel-builder-vanilla-types/dist/index.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,10 @@ export declare class Worksheet {
842842
* @returns {undefined}
843843
*/
844844
exportPageSettings(doc: XMLDOM, worksheet: XMLNode): void;
845+
/**
846+
* Serialize a chunk of rows to XML.
847+
*/
848+
serializeRows(rows: any[]): string;
845849
/**
846850
* http://www.schemacentral.com/sc/ooxml/t-ssml_ST_Orientation.html
847851
*
@@ -968,6 +972,8 @@ export declare class Workbook {
968972
generateFiles(): Promise<{
969973
[path: string]: string;
970974
}>;
975+
serializeHeader(): string;
976+
serializeFooter(): string;
971977
}
972978
export declare class Picture extends Drawing {
973979
id: string;
@@ -1043,6 +1049,22 @@ export declare function downloadExcelFile(workbook: Workbook, filename: string,
10431049
mimeType?: string;
10441050
zipOptions?: ZipOptions;
10451051
}): Promise<void>;
1052+
/**
1053+
* Async generator that yields zipped Excel file chunks.
1054+
* @param workbook Workbook instance
1055+
* @param options {chunkSize} Number of rows per chunk
1056+
*/
1057+
export declare function createExcelFileStream(workbook: Workbook, options?: {
1058+
chunkSize?: number;
1059+
}): AsyncGenerator<Uint8Array<ArrayBufferLike> | {
1060+
type: string;
1061+
name: string;
1062+
xml: string;
1063+
} | {
1064+
type: string;
1065+
xml: string;
1066+
name?: undefined;
1067+
}, void, unknown>;
10461068
/**
10471069
* Converts the characters "&", "<", ">", '"', and "'" in `string` to their
10481070
* corresponding HTML entities.

packages/excel-builder-vanilla/src/Excel/Workbook.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,4 +356,14 @@ export class Workbook {
356356
return resolve(files);
357357
});
358358
}
359+
360+
serializeHeader(): string {
361+
// Return workbook XML header
362+
return '<?xml version="1.0" encoding="UTF-8"?><workbook>';
363+
}
364+
365+
serializeFooter(): string {
366+
// Return workbook XML footer
367+
return '</workbook>';
368+
}
359369
}

packages/excel-builder-vanilla/src/Excel/Worksheet.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,4 +644,170 @@ export class Worksheet {
644644
setColumnFormats(columnFormats: ExcelColumnFormat[]) {
645645
this.columnFormats = columnFormats;
646646
}
647+
648+
/**
649+
* Returns worksheet XML header (everything before <sheetData>)
650+
*/
651+
getWorksheetXmlHeader(): string {
652+
// const doc = Util.createXmlDoc(Util.schemas.spreadsheetml, 'worksheet');
653+
// const worksheet = doc.documentElement;
654+
// worksheet.setAttribute('xmlns:r', Util.schemas.relationships);
655+
// worksheet.setAttribute('xmlns:mc', Util.schemas.markupCompat);
656+
657+
// let maxX = 0;
658+
// const data = this.data;
659+
// const columns = this.columns || [];
660+
// for (let row = 0, l = data.length; row < l; row++) {
661+
// const cellCount = data[row].length;
662+
// maxX = cellCount > maxX ? cellCount : maxX;
663+
// }
664+
665+
// if (maxX !== 0) {
666+
// worksheet.appendChild(
667+
// Util.createElement(doc, 'dimension', [
668+
// ['ref', `${Util.positionToLetterRef(1, 1)}:${Util.positionToLetterRef(maxX, String(data.length))}`],
669+
// ]),
670+
// );
671+
// } else {
672+
// worksheet.appendChild(Util.createElement(doc, 'dimension', [['ref', Util.positionToLetterRef(1, 1)]]));
673+
// }
674+
675+
// worksheet.appendChild(this.sheetView.exportXML(doc));
676+
677+
// if (this.columns.length) {
678+
// worksheet.appendChild(this.exportColumns(doc));
679+
// }
680+
681+
// // Add <sheetData> start tag
682+
// const xml = doc.toString();
683+
// return xml.substring(0, xml.indexOf('<sheetData>') + '<sheetData>'.length);
684+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
685+
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
686+
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
687+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
688+
<sheetData>`;
689+
}
690+
691+
/**
692+
* Returns worksheet XML footer (everything after </sheetData>)
693+
*/
694+
getWorksheetXmlFooter(): string {
695+
// const doc = Util.createXmlDoc(Util.schemas.spreadsheetml, 'worksheet');
696+
// const worksheet = doc.documentElement;
697+
698+
// // Add all elements after <sheetData>
699+
// if (this.sheetProtection) {
700+
// worksheet.appendChild(this.sheetProtection.exportXML(doc));
701+
// }
702+
703+
// if (this.hyperlinks.length > 0) {
704+
// const hyperlinksEl = doc.createElement('hyperlinks');
705+
// const hyperlinks = this.hyperlinks;
706+
// for (let i = 0, l = hyperlinks.length; i < l; i++) {
707+
// const hyperlinkEl = doc.createElement('hyperlink');
708+
// const hyperlink: any = hyperlinks[i];
709+
// hyperlinkEl.setAttribute('ref', String(hyperlink.cell));
710+
// hyperlink.id = Util.uniqueId('hyperlink');
711+
// this.relations.addRelation(
712+
// {
713+
// id: hyperlink.id,
714+
// target: hyperlink.location,
715+
// targetMode: hyperlink.targetMode || 'External',
716+
// },
717+
// 'hyperlink',
718+
// );
719+
// hyperlinkEl.setAttribute('r:id', this.relations.getRelationshipId(hyperlink));
720+
// hyperlinksEl.appendChild(hyperlinkEl);
721+
// }
722+
// worksheet.appendChild(hyperlinksEl);
723+
// }
724+
725+
// if (this.mergedCells.length > 0) {
726+
// const mergeCells = doc.createElement('mergeCells');
727+
// for (let i = 0, l = this.mergedCells.length; i < l; i++) {
728+
// const mergeCell = doc.createElement('mergeCell');
729+
// mergeCell.setAttribute('ref', `${this.mergedCells[i][0]}:${this.mergedCells[i][1]}`);
730+
// mergeCells.appendChild(mergeCell);
731+
// }
732+
// worksheet.appendChild(mergeCells);
733+
// }
734+
735+
// this.exportPageSettings(doc, worksheet);
736+
737+
// if (this._headers.length > 0 || this._footers.length > 0) {
738+
// const headerFooter = doc.createElement('headerFooter');
739+
// if (this._headers.length > 0) {
740+
// headerFooter.appendChild(this.exportHeader(doc));
741+
// }
742+
// if (this._footers.length > 0) {
743+
// headerFooter.appendChild(this.exportFooter(doc));
744+
// }
745+
// worksheet.appendChild(headerFooter);
746+
// }
747+
748+
// for (let i = 0, l = this._drawings.length; i < l; i++) {
749+
// const drawing = doc.createElement('drawing');
750+
// drawing.setAttribute('r:id', this.relations.getRelationshipId(this._drawings[i]));
751+
// worksheet.appendChild(drawing);
752+
// }
753+
754+
// if (this._tables.length > 0) {
755+
// const tables = doc.createElement('tableParts');
756+
// tables.setAttribute('count', this._tables.length);
757+
// for (let i = 0, l = this._tables.length; i < l; i++) {
758+
// const table = doc.createElement('tablePart');
759+
// table.setAttribute('r:id', this.relations.getRelationshipId(this._tables[i]));
760+
// tables.appendChild(table);
761+
// }
762+
// worksheet.appendChild(tables);
763+
// }
764+
765+
// // Get everything after </sheetData>
766+
// const xml = doc.toString();
767+
// return xml.substring(xml.indexOf('</sheetData>') + '</sheetData>'.length);
768+
return '';
769+
}
770+
771+
/**
772+
* Serialize a chunk of rows to XML (same logic as in toXML)
773+
*/
774+
serializeRows(rows: (number | string | boolean | Date | null | ExcelColumnMetadata)[][], startRow = 0): string {
775+
const columns = this.columns || [];
776+
let xml = '';
777+
for (let row = 0, l = rows.length; row < l; row++) {
778+
const dataRow = rows[row];
779+
const cellCount = dataRow.length;
780+
let rowXml = `<row r="${startRow + row + 1}">`;
781+
for (let c = 0; c < cellCount; c++) {
782+
let cellValue = dataRow[c];
783+
let cellType: any = typeof cellValue;
784+
// Always treat first row as text
785+
if (startRow + row === 0) {
786+
cellType = 'text';
787+
}
788+
let cellXml = '';
789+
const rAttr = ` r="${String.fromCharCode(65 + c)}${startRow + row + 1}"`;
790+
switch (cellType) {
791+
case 'number':
792+
cellXml = `<c${rAttr}><v>${cellValue}</v></c>`;
793+
break;
794+
case 'text':
795+
default: {
796+
let id: number | undefined;
797+
if (typeof this.sharedStrings?.strings[cellValue as string] !== 'undefined') {
798+
id = this.sharedStrings.strings[cellValue as string];
799+
} else {
800+
id = this.sharedStrings?.addString(cellValue as string);
801+
}
802+
cellXml = `<c${rAttr} t="s"><v>${id}</v></c>`;
803+
break;
804+
}
805+
}
806+
rowXml += cellXml;
807+
}
808+
rowXml += '</row>';
809+
xml += rowXml;
810+
}
811+
return xml;
812+
}
647813
}

packages/excel-builder-vanilla/src/factory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ZipOptions, strToU8, zip } from 'fflate';
1+
import { strToU8, type ZipOptions, zip } from 'fflate';
22

33
import { Workbook } from './Excel/Workbook.js';
44

packages/excel-builder-vanilla/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export { Workbook } from './Excel/Workbook.js';
1818
export { Worksheet } from './Excel/Worksheet.js';
1919
export { XMLDOM, XMLNode } from './Excel/XMLDOM.js';
2020
export { createExcelFile, createWorkbook, downloadExcelFile } from './factory.js';
21+
export { createExcelFileStream } from './streaming.js';
2122
export * from './interfaces.js';
2223
export { htmlEscape } from './utilities/escape.js';
2324
export { isObject, isPlainObject, isString } from './utilities/isTypeOf.js';

0 commit comments

Comments
 (0)