diff --git a/.gitignore b/.gitignore index c95225c..d783913 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ tsconfig.tsbuildinfo # Playwright playwright-report test-results -/playwright/.cache \ No newline at end of file +/playwright/.cache + +# Ignore temp folder for streaming demo +**/temp/ \ No newline at end of file diff --git a/README.md b/README.md index d97ddc5..0a5a63e 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ The modernization steps: - bump version to `v3.0.0` as a `major` release (_the original project version was in the `2.x` range._) - note that the changelog did not exists prior to `v3.0.0` - v4.x is now ESM-Only +- new Streaming API for large datasets The project now requires only 1 small dependency which is [fflate](https://github.com/101arrowz/fflate). diff --git a/biome.json b/biome.json index 479bfa8..c8c8d01 100644 --- a/biome.json +++ b/biome.json @@ -51,7 +51,8 @@ "recommended": true, "complexity": { "noForEach": "off", - "noStaticOnlyClass": "off" + "noStaticOnlyClass": "off", + "noUselessSwitchCase": "off" }, "performance": { "noBarrelFile": "off", diff --git a/docs/TOC.md b/docs/TOC.md index 5a1278f..2a8c17e 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -18,4 +18,5 @@ - [Theming Tables](theming-tables.md) - [Tables Summaries](tables-summaries.md) - [Adding Headers and Footers to a Worksheet](worksheet-headers-footers.md) -- [Inserting images into spreadsheets](inserting-pictures.md) + - [Inserting images into spreadsheets](inserting-pictures.md) + - [Streaming Excel Export](streaming.md) diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..50c9f50 --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,66 @@ +# Streaming Excel Export + +Streaming export is designed for large datasets, providing better performance and memory efficiency in both browser and NodeJS environments. The API and features are the same as the regular export and the features like formulas, alignment, borders, and more are all supported. + +## Why Streaming? + +Traditional export methods generate the entire Excel file in memory, which can hang the browser or consume excessive resources for large datasets. Streaming solves this by generating and delivering the file in chunks. + +## Usage in the Browser + +Use `createExcelFileStream` to export data as a stream. You can process chunks and update progress as needed. + +```ts +import { createWorkbook, createExcelFileStream } from 'excel-builder-vanilla'; + +const workbook = createWorkbook(); +const worksheet = workbook.createWorksheet({ name: 'Demo' }); +worksheet.setData([ + ['Artist', 'Album', 'Price'], + ['Buckethead', 'Albino Slug', 8.99], + // ... more rows +]); +workbook.addWorksheet(worksheet); + +const stream = createExcelFileStream(workbook, { chunkSize: 1000 }); +const chunks: Uint8Array[] = []; +for await (const chunk of stream as AsyncIterable) { + chunks.push(chunk); + // Optionally update progress bar here +} +const blob = new Blob(chunks, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +const url = URL.createObjectURL(blob); +// Download with anchor tag +``` + +## Usage in NodeJS + +Streaming in NodeJS works similarly, but you can pipe the output directly to a file stream. + +```js +import fs from 'node:fs'; +import { createWorkbook, createExcelFileStream } from 'excel-builder-vanilla'; + +const workbook = createWorkbook(); +// ... add data and worksheets + +const output = fs.createWriteStream('output.xlsx'); +for await (const chunk of createExcelFileStream(workbook, { chunkSize: 1000 })) { + output.write(chunk); +} +output.end(); +``` + +> **Note:** a Node script can be found in the [packages/demo/node-examples/](https://github.com/ghiscoding/excel-builder-vanilla/tree/main/packages/demo/node-examples/) folder. + +## Supported Features + +All features such as formulas, alignment, borders, styles, and images work with streaming export. The only difference is how the file is delivered. + +## See Also + +- [Formulas](formulas.md) +- [Alignment](alignment.md) +- [Borders](fonts-and-colors.md) +- [Tables](tables.md) +- [Headers/Footers](worksheet-headers-footers.md) diff --git a/docs/workbook-create.md b/docs/workbook-create.md index 2da9e06..897b0e0 100644 --- a/docs/workbook-create.md +++ b/docs/workbook-create.md @@ -21,3 +21,22 @@ const workbook = new Workbook(); This will eventually require you to include the 'excel-builder' module so you can export the workbook, so it's more verbose. However, this is also the best option for creating templates and the like. Workbooks with no worksheet (i.e. data) will build, but Excel will throw an error while attempting to open it. + +--- + +## NodeJS Usage Example + +You can use excel-builder-vanilla in NodeJS to generate and save Excel files directly to disk: + +```js +import fs from 'node:fs'; +import { createWorkbook, createExcelFile } from 'excel-builder-vanilla'; + +const workbook = createWorkbook(); +// ... add worksheets and data + +const buffer = createExcelFile(workbook); +fs.writeFileSync('output.xlsx', buffer); +``` + +> **Note:** a Node script can be found in the [packages/demo/node-examples/](https://github.com/ghiscoding/excel-builder-vanilla/tree/main/packages/demo/node-examples/) folder. \ No newline at end of file diff --git a/docs/worksheet-add-data.md b/docs/worksheet-add-data.md index 86ef988..88f00e1 100644 --- a/docs/worksheet-add-data.md +++ b/docs/worksheet-add-data.md @@ -25,3 +25,27 @@ artistWorkbook.addWorksheet(albumList); const data = createExcelFile(artistWorkbook); downloader('Artist WB.xlsx', data); ``` + +--- + +## NodeJS Usage Example + +You can add data to a worksheet and export in NodeJS: + +```js +import fs from 'node:fs'; +import { createWorkbook, createExcelFile } from 'excel-builder-vanilla'; + +const workbook = createWorkbook(); +const sheet = workbook.createWorksheet({ name: 'Demo' }); +sheet.setData([ + ['Artist', 'Album', 'Price'], + ['Buckethead', 'Albino Slug', 8.99], +]); +workbook.addWorksheet(sheet); + +const buffer = createExcelFile(workbook); +fs.writeFileSync('output.xlsx', buffer); +``` + +> **Note:** a Node script can be found in the [packages/demo/node-examples/](https://github.com/ghiscoding/excel-builder-vanilla/tree/main/packages/demo/node-examples/) folder. \ No newline at end of file diff --git a/docs/worksheet-create.md b/docs/worksheet-create.md index 5b96a63..200ee15 100644 --- a/docs/worksheet-create.md +++ b/docs/worksheet-create.md @@ -39,3 +39,27 @@ This will set the 'name' of the worksheet to 'Account Summary' so it doesn't sho ```ts const accountSummarySheet = workbook.createWorksheet({ name: 'Account Summary' }); ``` + +--- + +## NodeJS Usage Example + +Worksheets can be created and exported in NodeJS just like in the browser: + +```js +import fs from 'node:fs'; +import { createWorkbook, createExcelFile } from 'excel-builder-vanilla'; + +const workbook = createWorkbook(); +const sheet = workbook.createWorksheet({ name: 'Demo' }); +sheet.setData([ + ['Artist', 'Album', 'Price'], + ['Buckethead', 'Albino Slug', 8.99], +]); +workbook.addWorksheet(sheet); + +const buffer = createExcelFile(workbook); +fs.writeFileSync('output.xlsx', buffer); +``` + +> **Note:** a Node script can be found in the [packages/demo/node-examples/](https://github.com/ghiscoding/excel-builder-vanilla/tree/main/packages/demo/node-examples/) folder. \ No newline at end of file diff --git a/docs/worksheet-headers-footers.md b/docs/worksheet-headers-footers.md index b8035c8..984faad 100644 --- a/docs/worksheet-headers-footers.md +++ b/docs/worksheet-headers-footers.md @@ -49,3 +49,29 @@ artistWorkbook.addWorksheet(albumList); const data = createExcelFile(artistWorkbook); downloader('Artist WB.xlsx', data); ``` + +--- + +## NodeJS Usage Example + +Headers and footers work in NodeJS as well: + +```js +import fs from 'node:fs'; +import { createWorkbook, createExcelFile } from 'excel-builder-vanilla'; + +const workbook = createWorkbook(); +const sheet = workbook.createWorksheet({ name: 'Demo' }); +sheet.setData([ + ['Artist', 'Album', 'Price'], + ['Buckethead', 'Albino Slug', 8.99], +]); +sheet.setHeader(['Left', 'Center', 'Right']); +sheet.setFooter(['Date: &D', '&A', 'Page &P of &N']); +workbook.addWorksheet(sheet); + +const buffer = createExcelFile(workbook); +fs.writeFileSync('output.xlsx', buffer); +``` + +> **Note:** a Node script can be found in the [packages/demo/node-examples/](https://github.com/ghiscoding/excel-builder-vanilla/tree/main/packages/demo/node-examples/) folder. \ No newline at end of file diff --git a/package.json b/package.json index 90fd282..e5480c3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "url": "https://ko-fi.com/ghiscoding" }, "scripts": { - "clean": "remove --glob **/dist **/tsconfig.tsbuildinfo", + "clean": "remove --glob \"**/dist **/tsconfig.tsbuildinfo\"", "prebuild": "pnpm run clean && pnpm run biome:lint:write && pnpm run biome:format:write", "build": "pnpm -r --stream build", "build:demo": "pnpm -r --stream --filter \"./packages/demo/**\" build", @@ -45,7 +45,9 @@ "roll-new-release": "pnpm build && pnpm new-version && pnpm new-publish", "serve:demo": "pnpm -r --stream --filter \"./packages/demo/**\" dev", "test": "vitest --watch --config ./vitest/vitest.config.mts", - "test:coverage": "vitest --coverage --config ./vitest/vitest.config.mts" + "test:coverage": "vitest --coverage --config ./vitest/vitest.config.mts", + "demo:node:streaming": "node ./packages/demo/node-examples/node-streaming-demo.mjs", + "demo:node:non-streaming": "node ./packages/demo/node-examples/node-non-streaming-demo.mjs" }, "engines": { "node": "^20.17.0 || >=22.9.0", diff --git a/packages/demo/node-examples/node-non-streaming-demo.mjs b/packages/demo/node-examples/node-non-streaming-demo.mjs new file mode 100644 index 0000000..af14fc8 --- /dev/null +++ b/packages/demo/node-examples/node-non-streaming-demo.mjs @@ -0,0 +1,52 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createExcelFile, createWorkbook } from 'excel-builder-vanilla'; + +// Build data array (same as streaming example) +const ROWS = 1000; +const dataArray = []; + +// Add header row at row 0, merged and styled +const workbook = createWorkbook(); +const worksheet = workbook.createWorksheet({ name: 'Demo Non-Streaming' }); + +// Create a format for the header row +const stylesheet = workbook.getStyleSheet(); +const headerFormat = stylesheet.createFormat({ + alignment: { horizontal: 'center' }, + font: { bold: true, color: 'FF2b995d', size: 13 }, +}); + +dataArray.push([{ value: 'NodeJS Non-Streaming Output', metadata: { style: headerFormat.id } }]); +dataArray.push(['ID', 'Name', 'Score']); +for (let i = 1; i <= ROWS; i++) { + dataArray.push([i, `User ${i}`, Math.floor(Math.random() * 100)]); +} +// Add a formula cell for the total score +dataArray.push(['', 'Total', { value: `SUM(C2:C${ROWS + 2})`, metadata: { type: 'formula' } }]); + +worksheet.setData(dataArray); +worksheet.mergeCells('A1', 'C1'); +workbook.addWorksheet(worksheet); + +// Ensure temp folder exists +const tempDir = path.resolve(process.cwd(), 'temp'); +if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); +} +const outputPath = path.join(tempDir, 'node-non-streaming-example15.xlsx'); + +(async () => { + let buffer = await createExcelFile(workbook, { + outputType: 'Uint8Array', + fileFormat: 'xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + downloadType: 'node', + }); + // If buffer is a Blob (browser fallback), convert to ArrayBuffer + if (buffer instanceof Blob) { + buffer = new Uint8Array(await buffer.arrayBuffer()); + } + fs.writeFileSync(outputPath, buffer); + console.log(`Excel file written to ${outputPath}`); +})(); diff --git a/packages/demo/node-examples/node-streaming-demo.mjs b/packages/demo/node-examples/node-streaming-demo.mjs new file mode 100644 index 0000000..35b8f3f --- /dev/null +++ b/packages/demo/node-examples/node-streaming-demo.mjs @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createExcelFileStream, createWorkbook } from 'excel-builder-vanilla'; + +// Build data array (same as browser example) +const ROWS = 1000; +const dataArray = []; + +// Add header row at row 0, merged and styled +const workbook = createWorkbook(); +const worksheet = workbook.createWorksheet({ name: 'Demo Streaming' }); + +// Create a format for the header row +const stylesheet = workbook.getStyleSheet(); +const headerFormat = stylesheet.createFormat({ + alignment: { horizontal: 'center' }, + font: { bold: true, color: 'FF2b995d', size: 13 }, +}); + +dataArray.push([{ value: 'NodeJS Streaming Output', metadata: { style: headerFormat.id } }]); +dataArray.push(['ID', 'Name', 'Score']); +for (let i = 1; i <= ROWS; i++) { + dataArray.push([i, `User ${i}`, Math.floor(Math.random() * 100)]); +} +dataArray.push(['', 'Total', { value: `SUM(C2:C${ROWS + 2})`, metadata: { type: 'formula' } }]); + +worksheet.setData(dataArray); +worksheet.mergeCells('A1', 'C1'); +workbook.addWorksheet(worksheet); + +// Ensure temp folder exists +const tempDir = path.resolve(process.cwd(), 'temp'); +if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir); +} +const outputPath = path.join(tempDir, 'node-streaming-example15.xlsx'); +const output = fs.createWriteStream(outputPath); + +(async () => { + for await (const chunk of createExcelFileStream(workbook, { + zipOptions: {}, + outputType: 'Uint8Array', + fileFormat: 'xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + downloadType: 'node', + })) { + output.write(chunk); + } + output.end(); + console.log(`Excel file written to ${outputPath}`); +})(); diff --git a/packages/demo/package.json b/packages/demo/package.json index 655b46a..181e3d4 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "stream:excel": "node ./node-examples/node-streaming-demo.mjs", + "nonstream:excel": "node ./node-examples/node-non-streaming-demo.mjs" }, "dependencies": { "@excel-builder-vanilla/types": "workspace:*", diff --git a/packages/demo/src/app-routing.ts b/packages/demo/src/app-routing.ts index ecdef79..be95935 100644 --- a/packages/demo/src/app-routing.ts +++ b/packages/demo/src/app-routing.ts @@ -12,6 +12,9 @@ import Example11 from './examples/example11.js'; import Example12 from './examples/example12.js'; import Example13 from './examples/example13.js'; import Example14 from './examples/example14.js'; +import Example15 from './examples/example15.js'; +import Example16 from './examples/example16.js'; +import Example17 from './examples/example17.js'; import GettingStarted from './getting-started.js'; export const navbarRouting = [ @@ -42,6 +45,9 @@ export const exampleRouting = [ { name: 'example12', view: '/src/examples/example12.html', viewModel: Example12, title: '12- Worksheet Headers/Footers' }, { name: 'example13', view: '/src/examples/example13.html', viewModel: Example13, title: '13- Pictures with 2 anchors' }, { name: 'example14', view: '/src/examples/example14.html', viewModel: Example14, title: '14- Pictures with different anchors' }, + { name: 'example15', view: '/src/examples/example15.html', viewModel: Example15, title: '15- Streaming Excel Export' }, + { name: 'example16', view: '/src/examples/example16.html', viewModel: Example16, title: '16- Streaming Features Demo' }, + { name: 'example17', view: '/src/examples/example17.html', viewModel: Example17, title: '17- Streaming Export with Images' }, ], }, ]; diff --git a/packages/demo/src/examples/example01.ts b/packages/demo/src/examples/example01.ts index dd80e03..db4b0a2 100644 --- a/packages/demo/src/examples/example01.ts +++ b/packages/demo/src/examples/example01.ts @@ -1,5 +1,4 @@ import { downloadExcelFile, Workbook } from 'excel-builder-vanilla'; -// import type { ExcelStyleInstruction } from '@excel-builder-vanilla/types'; import './example01.scss'; diff --git a/packages/demo/src/examples/example02.html b/packages/demo/src/examples/example02.html index 4e2db67..c82585b 100644 --- a/packages/demo/src/examples/example02.html +++ b/packages/demo/src/examples/example02.html @@ -19,8 +19,10 @@

- The column widthattribute will set a width. The hiddenattribute will hide the column in Excel. The example - below has the "Artist" column wider and the next column "Album" to be hidden in the exported Excel file. + The column width + attribute will set a width. The hidden + attribute will hide the column in Excel. The example below has the "Artist" column wider and the next column "Album" to be hidden in + the exported Excel file.
diff --git a/packages/demo/src/examples/example03.html b/packages/demo/src/examples/example03.html index 547806d..3ad8ec6 100644 --- a/packages/demo/src/examples/example03.html +++ b/packages/demo/src/examples/example03.html @@ -19,8 +19,8 @@

- Set different row options via setRowInstructions()method. For example, we changed the row height of the first row and - change the text style to italic. + Set different row options via setRowInstructions() + method. For example, we changed the row height of the first row and change the text style to italic.
diff --git a/packages/demo/src/examples/example04.html b/packages/demo/src/examples/example04.html index 897f104..bf3fab8 100644 --- a/packages/demo/src/examples/example04.html +++ b/packages/demo/src/examples/example04.html @@ -19,8 +19,10 @@

- Set different fonts and colors via the createFormat()method, we can provide an object with the fontand - borderproperties. + Set different fonts and colors via the createFormat() + method, we can provide an object with the font + and border + properties.
diff --git a/packages/demo/src/examples/example05.html b/packages/demo/src/examples/example05.html index 5b4844a..57b988c 100644 --- a/packages/demo/src/examples/example05.html +++ b/packages/demo/src/examples/example05.html @@ -19,8 +19,8 @@

- We can create custom format by using the createFormat()method, in this example we formatted the "Price" column as - currency and the Modified Date is a Date format. + We can create custom format by using the createFormat() + method, in this example we formatted the "Price" column as currency and the Modified Date is a Date format.
diff --git a/packages/demo/src/examples/example07.html b/packages/demo/src/examples/example07.html index 1df9dd0..0598da3 100644 --- a/packages/demo/src/examples/example07.html +++ b/packages/demo/src/examples/example07.html @@ -19,8 +19,8 @@

- Set different background filling by using fillproperty which accepts a wide range of options like background color type - of gradient or pattern and different colors. + Set different background filling by using fill + property which accepts a wide range of options like background color type of gradient or pattern and different colors.
diff --git a/packages/demo/src/examples/example09.html b/packages/demo/src/examples/example09.html index 3661b2d..bf795da 100644 --- a/packages/demo/src/examples/example09.html +++ b/packages/demo/src/examples/example09.html @@ -19,10 +19,10 @@

- Tables are a feature that is apparently new to Office 2007+, with a comparable feature called a listin 2003 and below. - Basically, by putting data in a table, it gives the user some ways to filter and sort the data through UI. There are also some - formula benefits. Creating a table takes a few extra steps, mostly because of how a table's definition is really detached from a - worksheet. + Tables are a feature that is apparently new to Office 2007+, with a comparable feature called a list + in 2003 and below. Basically, by putting data in a table, it gives the user some ways to filter and sort the data through UI. There + are also some formula benefits. Creating a table takes a few extra steps, mostly because of how a table's definition is really + detached from a worksheet.
diff --git a/packages/demo/src/examples/example09.ts b/packages/demo/src/examples/example09.ts index 5c20c2a..12cd310 100644 --- a/packages/demo/src/examples/example09.ts +++ b/packages/demo/src/examples/example09.ts @@ -1,4 +1,4 @@ -import { Table, createWorkbook, downloadExcelFile } from 'excel-builder-vanilla'; +import { createWorkbook, downloadExcelFile, Table } from 'excel-builder-vanilla'; import './example09.scss'; diff --git a/packages/demo/src/examples/example10.html b/packages/demo/src/examples/example10.html index 3cc56a2..40a9358 100644 --- a/packages/demo/src/examples/example10.html +++ b/packages/demo/src/examples/example10.html @@ -20,7 +20,8 @@

Every once in a while you need a table theme that isn't available from the custom themes. You can use - createTableStyle()to change style for a section like the header row and/or the whole table. + createTableStyle() + to change style for a section like the header row and/or the whole table.
diff --git a/packages/demo/src/examples/example10.ts b/packages/demo/src/examples/example10.ts index a9aa315..b520481 100644 --- a/packages/demo/src/examples/example10.ts +++ b/packages/demo/src/examples/example10.ts @@ -1,4 +1,4 @@ -import { Table, createWorkbook, downloadExcelFile } from 'excel-builder-vanilla'; +import { createWorkbook, downloadExcelFile, Table } from 'excel-builder-vanilla'; import './example10.scss'; diff --git a/packages/demo/src/examples/example11.ts b/packages/demo/src/examples/example11.ts index 2761f90..c7c1bc9 100644 --- a/packages/demo/src/examples/example11.ts +++ b/packages/demo/src/examples/example11.ts @@ -1,4 +1,4 @@ -import { Table, createWorkbook, downloadExcelFile } from 'excel-builder-vanilla'; +import { createWorkbook, downloadExcelFile, Table } from 'excel-builder-vanilla'; import './example11.scss'; diff --git a/packages/demo/src/examples/example13.html b/packages/demo/src/examples/example13.html index 8db27ee..b90c02e 100644 --- a/packages/demo/src/examples/example13.html +++ b/packages/demo/src/examples/example13.html @@ -19,7 +19,8 @@

- You can insert pictures/images in Excel but it must be provided in base64format. + You can insert pictures/images in Excel but it must be provided in base64 + format.
diff --git a/packages/demo/src/examples/example13.ts b/packages/demo/src/examples/example13.ts index 7859c60..59e6b4a 100644 --- a/packages/demo/src/examples/example13.ts +++ b/packages/demo/src/examples/example13.ts @@ -1,4 +1,4 @@ -import { createWorkbook, downloadExcelFile, Drawings, Picture, Table } from 'excel-builder-vanilla'; +import { createWorkbook, Drawings, downloadExcelFile, Picture, Table } from 'excel-builder-vanilla'; import './example13.scss'; diff --git a/packages/demo/src/examples/example14.html b/packages/demo/src/examples/example14.html index 9c93925..d5bd78b 100644 --- a/packages/demo/src/examples/example14.html +++ b/packages/demo/src/examples/example14.html @@ -19,8 +19,9 @@

- You can insert pictures/images in Excel but it must be provided in base64format. There are multiple type of anchors - that you can use: oneCellAnchor/twoCellAnchor/absoluteAnchorcell anchors. + You can insert pictures/images in Excel but it must be provided in base64 format. There are multiple type of + anchors that you can use: oneCellAnchor  / twoCellAnchor / absoluteAnchor + cell anchors.
diff --git a/packages/demo/src/examples/example14.ts b/packages/demo/src/examples/example14.ts index 53addbf..45be3f1 100644 --- a/packages/demo/src/examples/example14.ts +++ b/packages/demo/src/examples/example14.ts @@ -1,4 +1,5 @@ -import { createWorkbook, downloadExcelFile, Drawings, Picture, Positioning } from 'excel-builder-vanilla'; +import { createWorkbook, Drawings, downloadExcelFile, Picture, Positioning } from 'excel-builder-vanilla'; + import strawberryImageData from '../images/strawberry.jpg?base64'; // images must be provided in the `base64` format, use a Vite loader plugin import strawberryUrl from '../images/strawberry.jpg?url'; diff --git a/packages/demo/src/examples/example15.html b/packages/demo/src/examples/example15.html new file mode 100644 index 0000000..67e3221 --- /dev/null +++ b/packages/demo/src/examples/example15.html @@ -0,0 +1,108 @@ +
+
+
+

+ Example 15: Streaming Excel Export (100,000 rows) +

+
+ For large datasets, streaming export is significantly more performant and memory-efficient compared to non-streaming export. This + example demonstrates streaming using createExcelFileStream. The export also includes Header & Footer. Export + progress is shown below. +
+
+
+
+ +
+
+ +
+ Header +
+
+ + + + + + + + +
This will be on the left + In the middle I shall be + Right, underlined and size of 16
+ +
+ Body +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ArtistAlbumPrice
Artist 1Album 1$8.99
Artist 2Album 2$13.99
Artist 3Album 3$11.34
Artist 4Album 4$10.54
Artist 5Album 5$10.64
Artist 6Album 6$8.99
Artist 7Album 7$9.99
Artist 8Album 8$12.49
Artist 9Album 9$7.99
Artist 10Album 10$15.00
.........
+
+
diff --git a/packages/demo/src/examples/example15.ts b/packages/demo/src/examples/example15.ts new file mode 100644 index 0000000..aa13536 --- /dev/null +++ b/packages/demo/src/examples/example15.ts @@ -0,0 +1,83 @@ +import { createExcelFileStream, createWorkbook, type ExcelColumnMetadata } from 'excel-builder-vanilla'; + +const ROWS = 100_000; + +export default class Example { + exportBtnElm!: HTMLButtonElement; + progressElm!: HTMLDivElement; + + mount() { + this.exportBtnElm = document.querySelector('#export') as HTMLButtonElement; + this.progressElm = document.querySelector('#progress') as HTMLDivElement; + this.exportBtnElm.addEventListener('click', this.startProcess.bind(this)); + } + + unmount() { + this.exportBtnElm.removeEventListener('click', this.startProcess.bind(this)); + } + + async startProcess() { + const originalData: (number | string | boolean | Date | null | ExcelColumnMetadata)[][] = [ + ['Artist', 'Album', { value: 'Price', metadata: {} }], + ]; + for (let i = 0; i < ROWS; i++) { + const price = Math.round(Math.random() * 10000) / 100; + originalData.push([`Artist ${i}`, `Album ${i}`, { value: price, metadata: {} }]); + } + + const artistWorkbook = createWorkbook(); + const albumList = artistWorkbook.createWorksheet({ name: 'Artists' }); + + // Apply currency format for Price column + const stylesheet = artistWorkbook.getStyleSheet(); + const currencyFormat = stylesheet.createFormat({ format: '$#,##0.00' }); + + // Update header to use currency style + const headerCell = originalData[0][2]; + if (typeof headerCell === 'object' && headerCell !== null && 'metadata' in headerCell && headerCell.metadata) { + headerCell.metadata.style = currencyFormat.id; + } + + // Update all rows to use currency style for Price + for (let i = 1; i < originalData.length; i++) { + const cell = originalData[i][2]; + if (typeof cell === 'object' && cell !== null && 'metadata' in cell && cell.metadata) { + cell.metadata.style = currencyFormat.id; + } + } + + albumList.setData(originalData); + albumList.setHeader([ + 'This will be on the left', + ['In the middle ', { text: 'I shall be', bold: true }], + { text: 'Right, underlined and size of 16', font: 16, underline: true }, + ]); + albumList.setFooter(['Date of print: &D &T', '&A', 'Page &P of &N']); + artistWorkbook.addWorksheet(albumList); + + // Streaming export + const stream = createExcelFileStream(artistWorkbook, { chunkSize: 1000 }); + const chunks: Uint8Array[] = []; + let processed = 0; + + // Use async iterator for both browser and Node + for await (const chunk of stream as AsyncIterable) { + chunks.push(chunk); + processed += 1000; + this.progressElm.textContent = `Exported ${Math.min(processed, ROWS)} / ${ROWS} rows...`; + } + + // Combine chunks and trigger download + const blob = new Blob( + chunks.map(chunk => chunk.slice()), + { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'LargeArtistWB.xlsx'; + a.click(); + URL.revokeObjectURL(url); + this.progressElm.textContent = `Export successfully ${ROWS} rows!`; + } +} diff --git a/packages/demo/src/examples/example16.html b/packages/demo/src/examples/example16.html new file mode 100644 index 0000000..3ee32e4 --- /dev/null +++ b/packages/demo/src/examples/example16.html @@ -0,0 +1,122 @@ +
+

+ Example 16: Streaming Features Demo (50,000 rows) +

+
+ This demo showcases merged features: merged header, row height, styles, fonts, colors, borders, number/date formatting alignment and + formulas (from Example 02-08 but using Streaming Export). +
+ + +
+
0%
+
+

Excel Output Preview

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Merged Header
ArtistAlbum (hidden column)PriceQuantityTotal
Artist 1Album 1$8.995$44.95
Artist 2Album 2$13.997$97.93
Artist 3Album 3$11.349$102.06
Artist 4Album 4$10.543$31.62
Artist 5Album 5$10.641$10.64
Artist 6Album 6$8.9956$503.44
Artist 7Album 7$9.992$19.98
Artist 8Album 8$12.494$49.96
Artist 9Album 9$7.998$63.92
Artist 10Album 10$15.003$45.00
...............
+
+
diff --git a/packages/demo/src/examples/example16.scss b/packages/demo/src/examples/example16.scss new file mode 100644 index 0000000..89612ed --- /dev/null +++ b/packages/demo/src/examples/example16.scss @@ -0,0 +1,19 @@ +.export-btn { + background: #2b995d; + color: #fff; + font-weight: bold; + border-radius: 4px; + padding: 0.5em 1em; + border: none; + cursor: pointer; +} + +.export-btn:hover { + background: #237a4a; +} + +.progress { + margin-top: 1em; + font-size: 1.1em; + color: #2b995d; +} diff --git a/packages/demo/src/examples/example16.ts b/packages/demo/src/examples/example16.ts new file mode 100644 index 0000000..5f969c5 --- /dev/null +++ b/packages/demo/src/examples/example16.ts @@ -0,0 +1,139 @@ +import { createExcelFileStream, createWorkbook, type ExcelColumnMetadata } from 'excel-builder-vanilla'; + +import './example16.scss'; + +const ROWS = 50_000; + +export default class Example { + exportBtnElm!: HTMLButtonElement; + + mount() { + this.exportBtnElm = document.querySelector('#export') as HTMLButtonElement; + this.exportBtnElm.addEventListener('click', this.startProcess.bind(this)); + } + + unmount() { + // remove event listeners to avoid DOM leaks + this.exportBtnElm.removeEventListener('click', this.startProcess.bind(this)); + } + + startProcess() { + const artistWorkbook = createWorkbook(); + const albumList = artistWorkbook.createWorksheet({ name: 'Album List' }); + + // Merged header row with style + albumList.mergeCells('A1', 'D1'); + const stylesheet = artistWorkbook.getStyleSheet(); + const header = stylesheet.createFormat({ + alignment: { horizontal: 'center' }, + font: { bold: true, color: 'FF2b995d', size: 13 }, + }); + + // Row height and style + const boldRow = stylesheet.createFormat({ font: { italic: true, underline: true } }); + albumList.setRowInstructions(2, { height: 40, style: boldRow.id }); + + // Fonts, colors, borders + const red = 'FFFF0000'; + const importantFormatter = stylesheet.createFormat({ + font: { bold: true, color: red }, + border: { + bottom: { color: red, style: 'thin' }, + top: { color: red, style: 'thin' }, + left: { color: red, style: 'thin' }, + right: { color: red, style: 'dotted' }, + }, + }); + const themeColor = stylesheet.createFormat({ font: { bold: true, color: { theme: 3 } } }); + + // Number/date formatting + const currency = stylesheet.createFormat({ format: '$#,##0.00' }); + + // Alignment + const centerAlign = stylesheet.createFormat({ alignment: { horizontal: 'center' } }); + + // Build large random dataset for export only, asynchronously + const originalData: (number | string | boolean | Date | null | ExcelColumnMetadata)[][] = [ + [{ value: 'Merged Header', metadata: { style: header.id } }, '', '', '', '', ''], + [ + { value: 'Artist', metadata: { style: importantFormatter.id } }, + { value: 'Album', metadata: { style: themeColor.id } }, + { value: 'Price', metadata: { style: themeColor.id } }, + { value: 'Quantity', metadata: { style: themeColor.id } }, + { value: 'Total', metadata: { style: themeColor.id } }, + ], + ]; + + async function generateDataAsync() { + const batchSize = 2000; + for (let i = 0; i < ROWS; i += batchSize) { + for (let j = 0; j < batchSize && i + j < ROWS; j++) { + const idx = i + j; + const artist = `Artist ${idx + 1}`; + const album = `Album ${idx + 1}`; + const price = Math.round(Math.random() * 10000) / 100; + const quantity = Math.floor(Math.random() * 10) + 1; + const rowNum = idx + 3; // +3 for header rows + originalData.push([ + { value: artist, metadata: { style: centerAlign.id } }, + { value: album, metadata: { style: centerAlign.id } }, + { value: price, metadata: { style: currency.id } }, + { value: quantity, metadata: { style: centerAlign.id } }, + { value: `C${rowNum}*D${rowNum}`, metadata: { type: 'formula', style: currency.id } }, + ]); + } + await new Promise(requestAnimationFrame); + } + } + + (async () => { + // Reset progress bar at the start of export, with a small delay for UI + const progressElm = document.getElementById('progress') as HTMLDivElement; + const progressBar = progressElm ? (progressElm.querySelector('.progress-bar') as HTMLDivElement) : null; + if (progressElm && progressBar) { + progressBar.style.width = '0%'; + progressBar.textContent = ''; + progressElm.setAttribute('aria-valuenow', '0'); + await new Promise(resolve => setTimeout(resolve, 50)); + } + + // Generate data asynchronously + await generateDataAsync(); + albumList.setData(originalData); + albumList.setColumns([{ width: 30 }, { width: 20 }, { width: 10 }, { width: 10 }, { width: 15 }]); + artistWorkbook.addWorksheet(albumList); + + // Streaming export with progress bar + const stream = createExcelFileStream(artistWorkbook, { chunkSize: 10 }); + const chunks: Uint8Array[] = []; + let processed = 0; + const totalRows = ROWS; + + for await (const chunk of stream as AsyncIterable) { + chunks.push(chunk); + processed += chunk.length; + if (progressElm && progressBar) { + const percent = Math.min((processed / totalRows) * 100, 100); + progressBar.style.width = `${percent}%`; + progressElm.setAttribute('aria-valuenow', percent.toString()); + progressBar.textContent = `${percent.toFixed(1)}%`; + void progressBar.offsetWidth; + } + // Artificial delay for demo purposes ONLY. Remove this in production for best performance. + // In a real implementation, use: await new Promise(requestAnimationFrame); + await new Promise(resolve => setTimeout(resolve, 30)); + } + // Combine chunks and trigger download + const blob = new Blob( + chunks.map(chunk => chunk.slice()), + { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'Artist WB - Streaming Features.xlsx'; + a.click(); + URL.revokeObjectURL(url); + })(); + } +} diff --git a/packages/demo/src/examples/example17.html b/packages/demo/src/examples/example17.html new file mode 100644 index 0000000..3f7b709 --- /dev/null +++ b/packages/demo/src/examples/example17.html @@ -0,0 +1,132 @@ +
+
+
+

+ Example 17: Streaming Export with Images + + Code + + html + | + ts + + +

+
+ Streaming Excel export with images using the new Streaming API. Images must be provided in base64 + format. +
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ A + + B + + C + + D + + E + + F + + G + + H +
+ Artist + + + Album + + + Price + +
BucketheadAlbino Slug8.99   
BucketheadElectric Tears13.99 + +
BucketheadColma11.34
Crystal MethodVegas10.54
Crystal MethodTweekend10.64
Crystal MethodDivided By Night8.99
+
+
+
diff --git a/packages/demo/src/examples/example17.ts b/packages/demo/src/examples/example17.ts new file mode 100644 index 0000000..fa12721 --- /dev/null +++ b/packages/demo/src/examples/example17.ts @@ -0,0 +1,79 @@ +import { createExcelFileStream, createWorkbook, Drawings, Picture, Table } from 'excel-builder-vanilla'; + +import './example13.scss'; + +export default class Example { + exportBtnElm!: HTMLButtonElement; + githubLogoBase64 = + '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'; + + mount() { + this.exportBtnElm = document.querySelector('#export') as HTMLButtonElement; + this.exportBtnElm.addEventListener('click', this.startProcess.bind(this)); + document.querySelector('#pic1')!.src = `data:image/png;base64,${this.githubLogoBase64}`; + } + + unmount() { + this.exportBtnElm.removeEventListener('click', this.startProcess.bind(this)); + } + + async startProcess() { + const workbook = createWorkbook(); + const worksheet = workbook.createWorksheet({ name: 'TestSheet' }); + + const originalData = [ + ['Artist', 'Album', 'Price'], + ['Buckethead', 'Albino Slug', 8.99], + ['Buckethead', 'Electric Tears', 13.99], + ['Buckethead', 'Colma', 11.34], + ['Crystal Method', 'Vegas', 10.54], + ['Crystal Method', 'Tweekend', 10.64], + ['Crystal Method', 'Divided By Night', 8.99], + ]; + + const albumTable = new Table(); + albumTable.styleInfo.themeStyle = 'TableStyleDark2'; + albumTable.setReferenceRange([1, 1], [3, originalData.length]); + albumTable.setTableColumns(['Artist', 'Album', 'Price']); + + worksheet.sheetView.showGridLines = false; + worksheet.setData(originalData); + workbook.addWorksheet(worksheet); + + worksheet.addTable(albumTable); + workbook.addTable(albumTable); + + const drawings = new Drawings(); + const picRef = workbook.addMedia('image', 'logo.png', this.githubLogoBase64); + const picture = new Picture(); + picture.createAnchor('twoCellAnchor', { + from: { + x: 5, + y: 2, + }, + to: { + x: 7, + y: 8, + }, + }); + + picture.setMedia(picRef); + drawings.addDrawing(picture); + worksheet.addDrawings(drawings); + workbook.addDrawings(drawings); + + // Streaming export + const stream = createExcelFileStream(workbook, { chunkSize: 1024 }); + const chunks: Uint8Array[] = []; + for await (const chunk of stream as AsyncIterable) { + chunks.push(chunk); + } + const blob = new Blob(chunks as BlobPart[], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'Fruits-Streaming.xlsx'; + a.click(); + URL.revokeObjectURL(url); + } +} diff --git a/packages/demo/src/getting-started.html b/packages/demo/src/getting-started.html index 3adae83..5286a33 100644 --- a/packages/demo/src/getting-started.html +++ b/packages/demo/src/getting-started.html @@ -20,8 +20,8 @@
CDN

- jsDelivrgraciously provide CDNs for many JavaScript libraries including - Excel-Builder-Vanilla. Just use the following CDN links. + jsDelivr + graciously provide CDNs for many JavaScript libraries including Excel-Builder-Vanilla. Just use the following CDN links.

The project now ships as ESM-Only, if you still wish to use the legacy CommonJS (CJS) format with require(), then use diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 618ecae..884d0f7 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -337,13 +337,16 @@ export interface ExcelBorderStyle { } export interface ExcelColumn { bestFit?: boolean; + collapsed?: boolean; customWidth?: number; hidden?: boolean; - min?: number; max?: number; + min?: number; + outlineLevel?: number; + phonetic?: boolean; + style?: number; width?: number; } -export type ExcelColumnFormat = "bestFit" | "collapsed" | "customWidth" | "hidden" | "max" | "min" | "outlineLevel" | "phonetic" | "style" | "width"; export interface ExcelTableColumn { name: string; dataCellStyle?: any; @@ -679,7 +682,7 @@ export declare class Worksheet { id: string; _timezoneOffset: number; relations: any; - columnFormats: ExcelColumnFormat[]; + columnFormats: ExcelColumn[]; data: (number | string | boolean | Date | null | ExcelColumnMetadata)[][]; mergedCells: string[][]; columns: ExcelColumn[]; @@ -705,7 +708,12 @@ export declare class Worksheet { cell?: string; }; sharedStrings: SharedStrings | null; - hyperlinks: never[]; + hyperlinks: Array<{ + cell: string; + id: string; + location?: string; + targetMode?: string; + }>; sheetView: SheetView; showZeros: any; constructor(config: WorksheetOption); @@ -716,7 +724,7 @@ export declare class Worksheet { */ exportData(): { relations: any; - columnFormats: ExcelColumnFormat[]; + columnFormats: ExcelColumn[]; data: (string | number | boolean | Date | ExcelColumnMetadata | null)[][]; columns: ExcelColumn[]; mergedCells: string[][]; @@ -909,7 +917,19 @@ export declare class Worksheet { * width * @param {Array} columnFormats */ - setColumnFormats(columnFormats: ExcelColumnFormat[]): void; + setColumnFormats(columnFormats: ExcelColumn[]): void; + /** + * Returns worksheet XML header (everything before ) + */ + getWorksheetXmlHeader(): string; + /** + * Returns worksheet XML footer (everything after ) + */ + getWorksheetXmlFooter(): string; + /** + * Serialize a chunk of rows to XML (same logic as in toXML) + */ + serializeRows(rows: (number | string | boolean | Date | null | ExcelColumnMetadata)[][], startRow?: number): string; } export interface MediaMeta { id: string; @@ -968,6 +988,10 @@ export declare class Workbook { generateFiles(): Promise<{ [path: string]: string; }>; + /** Return workbook XML header */ + serializeHeader(): string; + /** Return workbook XML footer */ + serializeFooter(): string; } export declare class Picture extends Drawing { id: string; @@ -1043,6 +1067,19 @@ export declare function downloadExcelFile(workbook: Workbook, filename: string, mimeType?: string; zipOptions?: ZipOptions; }): Promise; +export interface ExcelFileStreamOptions { + chunkSize?: number; + outputType?: "Uint8Array" | "Blob" | "stream"; + fileFormat?: "xlsx" | "xls"; + mimeType?: string; + zipOptions?: import("fflate").ZipOptions; + downloadType?: "browser" | "node"; +} +/** + * Environment-aware streaming Excel file generator. + * Yields zipped chunks for browser (ReadableStream) or NodeJS (async generator). + */ +export declare function createExcelFileStream(workbook: Workbook, options?: ExcelFileStreamOptions): ReadableStream> | AsyncGenerator, void, unknown>; /** * Converts the characters "&", "<", ">", '"', and "'" in `string` to their * corresponding HTML entities. diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts new file mode 100644 index 0000000..2d9efa1 --- /dev/null +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; + +import { Chart } from '../Chart.js'; + +describe('Chart', () => { + it('can be instantiated', () => { + const chart = new Chart(); + expect(chart).toBeInstanceOf(Chart); + }); +}); diff --git a/packages/excel-builder-vanilla/src/Excel/Workbook.ts b/packages/excel-builder-vanilla/src/Excel/Workbook.ts index 9caab74..f972625 100644 --- a/packages/excel-builder-vanilla/src/Excel/Workbook.ts +++ b/packages/excel-builder-vanilla/src/Excel/Workbook.ts @@ -346,7 +346,8 @@ export class Workbook { this._generateCorePaths(files); for (let i = 0, l = this.worksheets.length; i < l; i++) { - files[`/xl/worksheets/sheet${i + 1}.xml`] = this.worksheets[i].toXML(); + const xml = this.worksheets[i].toXML(); + files[`/xl/worksheets/sheet${i + 1}.xml`] = xml; Paths[this.worksheets[i].id] = `worksheets/sheet${i + 1}.xml`; files[`/xl/worksheets/_rels/sheet${i + 1}.xml.rels`] = this.worksheets[i].relations.toXML(); } @@ -356,4 +357,14 @@ export class Workbook { return resolve(files); }); } + + /** Return workbook XML header */ + serializeHeader(): string { + return ''; + } + + /** Return workbook XML footer */ + serializeFooter(): string { + return ''; + } } diff --git a/packages/excel-builder-vanilla/src/Excel/Worksheet.ts b/packages/excel-builder-vanilla/src/Excel/Worksheet.ts index 5879e62..5b30246 100644 --- a/packages/excel-builder-vanilla/src/Excel/Worksheet.ts +++ b/packages/excel-builder-vanilla/src/Excel/Worksheet.ts @@ -1,4 +1,4 @@ -import type { ExcelColumn, ExcelColumnFormat, ExcelColumnMetadata, ExcelMargin, ExcelStyleInstruction } from '../interfaces.js'; +import type { ExcelColumn, ExcelColumnMetadata, ExcelMargin, ExcelStyleInstruction } from '../interfaces.js'; import { isObject, isString } from '../utilities/isTypeOf.js'; import { uniqueId } from '../utilities/uniqueId.js'; import type { Drawings } from './Drawings.js'; @@ -33,7 +33,7 @@ export class Worksheet { id = uniqueId('Worksheet'); _timezoneOffset: number; relations: any = null; - columnFormats: ExcelColumnFormat[] = []; + columnFormats: ExcelColumn[] = []; data: (number | string | boolean | Date | null | ExcelColumnMetadata)[][] = []; mergedCells: string[][] = []; columns: ExcelColumn[] = []; @@ -48,7 +48,7 @@ export class Worksheet { _freezePane: { xSplit?: number; ySplit?: number; cell?: string } = {}; sharedStrings: SharedStrings | null = null; - hyperlinks = []; + hyperlinks: Array<{ cell: string; id: string; location?: string; targetMode?: string }> = []; sheetView: SheetView; showZeros: any = null; @@ -354,7 +354,6 @@ export class Worksheet { cell = cellCache.formula.cloneNode(true); cell.firstChild.firstChild.nodeValue = cellValue as string; break; - // biome-ignore lint: original implementation case 'text': /*falls through*/ default: { @@ -641,7 +640,73 @@ export class Worksheet { * width * @param {Array} columnFormats */ - setColumnFormats(columnFormats: ExcelColumnFormat[]) { + setColumnFormats(columnFormats: ExcelColumn[]) { this.columnFormats = columnFormats; } + + /** + * Returns worksheet XML header (everything before ) + */ + getWorksheetXmlHeader(): string { + return ` +`; + } + + /** + * Returns worksheet XML footer (everything after ) + */ + getWorksheetXmlFooter(): string { + if (this._headers.length > 0 || this._footers.length > 0) { + let xml = ''; + if (this._headers.length > 0) { + xml += `${this.compilePageDetailPackage(this._headers)}`; + } + if (this._footers.length > 0) { + xml += `${this.compilePageDetailPackage(this._footers)}`; + } + xml += ''; + return xml; + } + return ''; + } + + /** + * Serialize a chunk of rows to XML (same logic as in toXML) + */ + serializeRows(rows: (number | string | boolean | Date | null | ExcelColumnMetadata)[][], startRow = 0): string { + let xml = ''; + for (let row = 0, l = rows.length; row < l; row++) { + const dataRow = rows[row]; + const cellCount = dataRow.length; + let rowXml = ``; + for (let c = 0; c < cellCount; c++) { + const cellValue = dataRow[c]; + const cellType: any = typeof cellValue || 'text'; + let cellXml = ''; + const rAttr = ` r="${String.fromCharCode(65 + c)}${startRow + row + 1}"`; + switch (cellType) { + case 'number': + cellXml = `${cellValue}`; + break; + case 'text': + default: { + let id: number | undefined; + if (typeof this.sharedStrings?.strings[cellValue as string] !== 'undefined') { + id = this.sharedStrings.strings[cellValue as string]; + } else { + id = this.sharedStrings?.addString(cellValue as string); + } + cellXml = `${id}`; + break; + } + } + rowXml += cellXml; + } + rowXml += ''; + xml += rowXml; + } + return xml; + } } diff --git a/packages/excel-builder-vanilla/src/__tests__/streaming.spec.ts b/packages/excel-builder-vanilla/src/__tests__/streaming.spec.ts new file mode 100644 index 0000000..6388b9e --- /dev/null +++ b/packages/excel-builder-vanilla/src/__tests__/streaming.spec.ts @@ -0,0 +1,228 @@ +import { unzipSync } from 'fflate'; +import { describe, expect, it } from 'vitest'; + +import { createWorkbook } from '../factory.js'; +import { createExcelFileStream } from '../streaming.js'; +import { Worksheet } from '../Excel/Worksheet.js'; +import { Workbook } from '../Excel/Workbook.js'; + +describe('Worksheet.setColumnFormats', () => { + it('sets columnFormats property', () => { + const ws = new Worksheet({ name: 'TestSheet' }); + // Use valid ExcelColumn objects + const formats = [{ width: 20 }, { hidden: true }]; + ws.setColumnFormats(formats); + expect(ws.columnFormats).toBe(formats); + }); +}); + +// Basic streaming test for NodeJS and browser-like environments + +describe('Streaming API', () => { + describe('Streaming API edge cases', () => { + describe('NodeJS streaming', () => { + it('createExcelFileStream delegates to nodeExcelStream in NodeJS', async () => { + // Simulate NodeJS environment + const originalWindow = globalThis.window; + const originalProcess = globalThis.process; + // @ts-ignore + delete globalThis.window; + globalThis.process = { versions: { node: '18.0.0' } } as any; + const { createExcelFileStream } = await import('../streaming.js'); + let called = false; + const fakeWorkbook: any = { + async generateFiles() { + called = true; + return { + 'xl/worksheet.xml': 'sheet', + 'xl/media/image.png': btoa('fakebinary'), + }; + }, + }; + const result = createExcelFileStream(fakeWorkbook, {}); + const chunks: Uint8Array[] = []; + for await (const chunk of result) { + chunks.push(chunk); + } + expect(called).toBe(true); + // Restore + globalThis.window = originalWindow; + globalThis.process = originalProcess; + }); + + it('nodeExcelStream yields zipped chunks and covers non-XML file', async () => { + // Mock workbook with generateFiles returning both XML and non-XML + const workbook: any = { + async generateFiles() { + return { + 'xl/worksheet.xml': 'sheet', + 'xl/media/image.png': btoa('fakebinary'), + }; + }, + }; + // Import nodeExcelStream directly + const { nodeExcelStream } = await import('../streaming.js'); + const chunks: Uint8Array[] = []; + for await (const chunk of nodeExcelStream(workbook)) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + // Concatenate chunks correctly for unzipSync + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + const zipped = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + zipped.set(chunk, offset); + offset += chunk.length; + } + const files = unzipSync(zipped); + expect(Object.keys(files)).toContain('xl/worksheet.xml'); + expect(Object.keys(files)).toContain('xl/media/image.png'); + }); + }); + + it('throws on unsupported environment', () => { + // Simulate an unsupported environment + const originalWindow = globalThis.window; + const originalProcess = globalThis.process; + // @ts-ignore + delete globalThis.window; + // @ts-ignore + delete globalThis.process; + const workbook = createWorkbook(); + expect(() => createExcelFileStream(workbook)).toThrow('Streaming is only supported in browser or NodeJS environments.'); + // Restore + globalThis.window = originalWindow; + globalThis.process = originalProcess; + }); + + it('handles empty workbook', async () => { + const workbook = createWorkbook(); + const chunks: Uint8Array[] = []; + for await (const chunk of createExcelFileStream(workbook)) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + }); + + it('respects chunkSize option', async () => { + const workbook = createWorkbook(); + const ws = workbook.createWorksheet({ name: 'Sheet1' }); + ws.setData([ + ['A', 'B'], + [1, 2], + ]); + workbook.addWorksheet(ws); + const chunks: Uint8Array[] = []; + for await (const chunk of createExcelFileStream(workbook, { chunkSize: 1 })) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + }); + }); + + it('should stream Excel file chunks', async () => { + const workbook = createWorkbook(); + const worksheet = workbook.createWorksheet({ name: 'Sheet1' }); + worksheet.setData([ + ['Artist', 'Album', 'Price'], + ['Buckethead', 'Albino Slug', 8.99], + ['Crystal Method', 'Vegas', 10.54], + ]); + workbook.addWorksheet(worksheet); + + const chunks: Uint8Array[] = []; + for await (const chunk of createExcelFileStream(workbook, { chunkSize: 1024 })) { + expect(chunk).toBeInstanceOf(Uint8Array); + chunks.push(chunk); + } + // Should produce a non-empty file + const totalSize = chunks.reduce((sum, c) => sum + c.length, 0); + expect(totalSize).toBeGreaterThan(0); + }); + + it('should stream Excel file with formulas and styles', async () => { + const workbook = createWorkbook(); + const worksheet = workbook.createWorksheet({ name: 'Sheet2' }); + worksheet.setData([ + [{ value: 'Artist' }, { value: 'Price' }, { value: 'Total' }], + ['Buckethead', 8.99, { value: 'B2*2', metadata: { type: 'formula' } }], + ['Crystal Method', 10.54, { value: 'B3*2', metadata: { type: 'formula' } }], + ]); + workbook.addWorksheet(worksheet); + + const chunks: Uint8Array[] = []; + for await (const chunk of createExcelFileStream(workbook, { chunkSize: 512 })) { + expect(chunk).toBeInstanceOf(Uint8Array); + chunks.push(chunk); + } + const totalSize = chunks.reduce((sum, c) => sum + c.length, 0); + expect(totalSize).toBeGreaterThan(0); + }); + + describe('Worksheet XML helpers', () => { + it('getWorksheetXmlHeader returns correct header', () => { + const ws = new Worksheet({ name: 'Test' }); + const header = ws.getWorksheetXmlHeader(); + expect(header).toContain(''); + expect(header).toContain(' { + const ws = new Worksheet({ name: 'Test' }); + expect(ws.getWorksheetXmlFooter()).toBe(''); + }); + + it('getWorksheetXmlFooter returns headerFooter XML if header/footer set', () => { + const ws = new Worksheet({ name: 'Test' }); + ws.setHeader(['Left', 'Center', 'Right']); + ws.setFooter(['FLeft', 'FCenter', 'FRight']); + const xml = ws.getWorksheetXmlFooter(); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('Left'); + expect(xml).toContain('FLeft'); + }); + }); + + describe('serializeRows', () => { + it('serializes text and number rows correctly', () => { + const ws = new Worksheet({ name: 'Test' }); + ws.sharedStrings = { strings: {}, addString: () => 0 } as any; + const rows = [ + ['Header1', 'Header2'], + [123, 'abc'], + ]; + const xml = ws.serializeRows(rows); + expect(xml).toContain(''); + expect(xml).toContain('0'); + expect(xml).toContain('0'); + }); + + it('serializes formula cells', () => { + const ws = new Worksheet({ name: 'Test' }); + ws.sharedStrings = { strings: {}, addString: () => 0 } as any; + const rows = [ + ['Header1', 'Header2'], + [{ value: 'A2+B2', metadata: { type: 'formula' } }, 42], + ]; + const xml = ws.serializeRows(rows); + expect(xml).toContain('0'); + expect(xml).toContain('42'); + }); + }); +}); + +describe('Workbook XML serialization', () => { + it('serializeHeader returns correct XML header', () => { + const wb = new Workbook(); + expect(wb.serializeHeader()).toBe(''); + }); + + it('serializeFooter returns correct XML footer', () => { + const wb = new Workbook(); + expect(wb.serializeFooter()).toBe(''); + }); +}); diff --git a/packages/excel-builder-vanilla/src/factory.ts b/packages/excel-builder-vanilla/src/factory.ts index 511aa5b..6a70638 100644 --- a/packages/excel-builder-vanilla/src/factory.ts +++ b/packages/excel-builder-vanilla/src/factory.ts @@ -1,4 +1,4 @@ -import { type ZipOptions, strToU8, zip } from 'fflate'; +import { strToU8, type ZipOptions, zip } from 'fflate'; import { Workbook } from './Excel/Workbook.js'; diff --git a/packages/excel-builder-vanilla/src/index.ts b/packages/excel-builder-vanilla/src/index.ts index e2852fc..481560a 100644 --- a/packages/excel-builder-vanilla/src/index.ts +++ b/packages/excel-builder-vanilla/src/index.ts @@ -18,7 +18,8 @@ export { Workbook } from './Excel/Workbook.js'; export { Worksheet } from './Excel/Worksheet.js'; export { XMLDOM, XMLNode } from './Excel/XMLDOM.js'; export { createExcelFile, createWorkbook, downloadExcelFile } from './factory.js'; -export * from './interfaces.js'; +export { createExcelFileStream } from './streaming.js'; +export type * from './interfaces.js'; export { htmlEscape } from './utilities/escape.js'; export { isObject, isPlainObject, isString } from './utilities/isTypeOf.js'; export { pick } from './utilities/pick.js'; diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index db83a0c..f5d6906 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -41,25 +41,17 @@ export interface ExcelBorderStyle { export interface ExcelColumn { bestFit?: boolean; + collapsed?: boolean; customWidth?: number; hidden?: boolean; - min?: number; max?: number; + min?: number; + outlineLevel?: number; + phonetic?: boolean; + style?: number; width?: number; } -export type ExcelColumnFormat = - | 'bestFit' - | 'collapsed' - | 'customWidth' - | 'hidden' - | 'max' - | 'min' - | 'outlineLevel' - | 'phonetic' - | 'style' - | 'width'; - export interface ExcelTableColumn { name: string; dataCellStyle?: any; @@ -99,21 +91,6 @@ export interface ExcelFontStyle { underline?: boolean | 'single' | 'double' | 'singleAccounting' | 'doubleAccounting'; } -// type ExcelMetadataType = -// | 'general' -// | 'number' -// | 'currency' -// | 'accounting' -// | 'date' -// | 'time' -// | 'percentage' -// | 'formula' -// | 'fraction' -// | 'scientific' -// | 'text' -// | 'special' -// | 'custom'; - export interface ExcelMetadata { type?: string; style?: number; @@ -159,10 +136,7 @@ export interface ExcelStyleInstruction { numFmtId?: number; width?: number; xfId?: number; - protection?: { - locked?: boolean; - hidden?: boolean; - }; + protection?: { locked?: boolean; hidden?: boolean }; /** style id */ style?: number; } diff --git a/packages/excel-builder-vanilla/src/streaming.ts b/packages/excel-builder-vanilla/src/streaming.ts new file mode 100644 index 0000000..0f6fffa --- /dev/null +++ b/packages/excel-builder-vanilla/src/streaming.ts @@ -0,0 +1,87 @@ +import { strToU8, type ZipOptions, zipSync } from 'fflate'; +import type { Workbook } from './Excel/Workbook.js'; +import { base64ToUint8Array } from './factory.js'; + +export interface ExcelFileStreamOptions { + chunkSize?: number; + outputType?: 'Blob' | 'Uint8Array' | 'stream'; + fileFormat?: 'xlsx' | 'xls'; + mimeType?: string; + zipOptions?: ZipOptions; + downloadType?: 'browser' | 'node'; +} + +/** + * Environment-aware streaming Excel file generator. + * Yields zipped chunks for browser (ReadableStream) or NodeJS (async generator). + */ +export function createExcelFileStream(workbook: Workbook, options?: ExcelFileStreamOptions) { + const isBrowser = typeof window !== 'undefined' && typeof window.ReadableStream !== 'undefined'; + const isNode = typeof process !== 'undefined' && process.versions?.node; + if (isBrowser) { + return browserExcelStream(workbook, options); + } + if (isNode) { + return nodeExcelStream(workbook, options); + } + throw new Error('Streaming is only supported in browser or NodeJS environments.'); +} + +/** + * Browser: returns a ReadableStream of zipped Excel file chunks. + */ +function browserExcelStream(workbook: Workbook, options?: ExcelFileStreamOptions) { + const stream = new ReadableStream({ + async start(controller) { + // Use workbook.generateFiles() to get all required files + const files = await workbook.generateFiles(); + const zipObj: { [name: string]: Uint8Array } = {}; + for (const [path, content] of Object.entries(files)) { + const outPath = path.startsWith('/') ? path.substr(1) : path; + if (path.indexOf('.xml') !== -1 || path.indexOf('.rel') !== -1) { + zipObj[outPath] = strToU8(String(content)); + } else { + zipObj[outPath] = base64ToUint8Array(String(content)); + } + } + // Synchronous zip for browser, split into chunks + const zipped: Uint8Array = zipSync(zipObj, options?.zipOptions || {}); + const chunkByteSize = 64 * 1024; // 64KB per chunk + let offset = 0; + while (offset < zipped.length) { + const chunk = zipped.subarray(offset, offset + chunkByteSize); + controller.enqueue(chunk); + offset += chunkByteSize; + await new Promise(r => setTimeout(r, 0)); + } + controller.close(); + }, + }); + return stream; +} + +/** + * NodeJS: returns an async generator yielding zipped Excel file chunks. + */ +export async function* nodeExcelStream(workbook: Workbook, options?: ExcelFileStreamOptions) { + const files = await workbook.generateFiles(); + const zipObj: { [name: string]: Uint8Array } = {}; + for (const [path, content] of Object.entries(files)) { + const outPath = path.startsWith('/') ? path.substr(1) : path; + if (path.indexOf('.xml') !== -1 || path.indexOf('.rel') !== -1) { + zipObj[outPath] = strToU8(String(content)); + } else { + zipObj[outPath] = base64ToUint8Array(String(content)); + } + } + // Synchronous zip for Node, split into chunks + const zipped: Uint8Array = zipSync(zipObj, options?.zipOptions || {}); + const chunkByteSize = 64 * 1024; // 64KB per chunk + let offset = 0; + while (offset < zipped.length) { + const chunk = zipped.subarray(offset, offset + chunkByteSize); + yield chunk; + offset += chunkByteSize; + await new Promise(r => setTimeout(r, 0)); + } +}