Skip to content

Commit

Permalink
feat: implement sheet-level cache manager
Browse files Browse the repository at this point in the history
  • Loading branch information
ChronicStone committed Apr 28, 2024
1 parent c5eb5e2 commit 418e7ff
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 49 deletions.
3 changes: 2 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default defineConfig({
},
lastUpdated: true,
ignoreDeadLinks: true,
cleanUrls: false,
cleanUrls: true,
router: {
prefetchLinks: true,
},
Expand All @@ -48,6 +48,7 @@ export default defineConfig({
['meta', { name: 'google-site-verification', content: 'DPVOPrsgdIJ4_xJYhy6Azw6vGw51riJiJoaT7SBTARc' }],
['link', { rel: 'shortcut icon', href: '/favicon.ico' }],
['meta', { property: 'og:type', content: 'website' }],
// ['script', { src: 'https://buttons.github.io/buttons.js', defer: 'true', async: 'true' }],
],
themeConfig: {
logo: '/images/logo.png',
Expand Down
2 changes: 1 addition & 1 deletion docs/.vitepress/theme/components/ExampleRenderer.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useData } from 'vitepress'
import { darkTheme } from 'naive-ui'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { THEME_OVERRIDES } from '../config/themeVars'
// @ts-expect-error missing types
Expand Down
Binary file modified examples/financial-report.xlsx
Binary file not shown.
Binary file modified examples/kitchen-sink.xlsx
Binary file not shown.
Binary file modified examples/playground.xlsx
Binary file not shown.
59 changes: 25 additions & 34 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable ts/ban-types */
import XLSX, { type WorkSheet, utils } from 'xlsx-js-style'
import type { CellValue, Column, ColumnGroup, ExcelBuildOutput, ExcelBuildParams, ExcelSchema, GenericObject, NestedPaths, Not, SchemaColumnKeys, SheetConfig, SheetParams, SheetTable, SheetTableBuilder, TOutputType, TransformersMap } from './types'
import { CacheManager, applyGroupBorders, buildSheetConfig, computeSheetRange, createCell, getCellValue, getColumnHeaderStyle, getColumnSeparatorIndexes, getPrevRowsHeight, getRowMaxHeight, getSheetChunkMaxHeight, getWorksheetColumnWidths, splitIntoChunks, tableHasSummary } from './utils'
import { SheetCacheManager, applyGroupBorders, buildSheetConfig, computeSheetRange, createCell, getCellValue, getColumnHeaderStyle, getColumnSeparatorIndexes, getPrevRowsHeight, getRowMaxHeight, getSheetChunkMaxHeight, getWorksheetColumnWidths, splitIntoChunks, tableHasSummary } from './utils'

export type * from './types'

Expand Down Expand Up @@ -137,44 +137,35 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
>(params: ExcelBuildParams<OutputType>,
): Output {
const workbook = utils.book_new()
const _sheets = buildSheetConfig(this.sheets)
const sheetsConfig = buildSheetConfig(this.sheets)
const sheetCacheManager = new SheetCacheManager(sheetsConfig)

const TABLE_CELL_OFFSET = 1

_sheets.forEach((sheetConfig) => {
const tableRows = splitIntoChunks(sheetConfig.tables, sheetConfig.params?.tablesPerRow)
sheetCacheManager.getSheets().forEach((sheetConfig, sheetIndex) => {
const tableChunks = sheetConfig.chunks
const worksheet: WorkSheet & { '!merges': XLSX.Range[] } = {
'!merges': [],
}
let COL_OFFSET = 0
let ROW_OFFSET = 0
const titleRowIndexes: number[] = []

tableRows.forEach((tables, rowIndex) => {
tableChunks.forEach((chunk, chunkIndex) => {
COL_OFFSET = 0
if (rowIndex > 0) {
const prevRow = tableRows[rowIndex - 1]
ROW_OFFSET += getSheetChunkMaxHeight(prevRow) + TABLE_CELL_OFFSET
}
if (chunkIndex > 0)
ROW_OFFSET += TABLE_CELL_OFFSET + (chunkIndex > 0 ? sheetCacheManager.getSheetChunk({ sheetIndex, chunkIndex })?.maxHeight ?? 0 : 0)

const rowHasTitle = tables.some(table => !!table.title)
if (rowHasTitle)
if (chunk.hasTitle)
titleRowIndexes.push(ROW_OFFSET)

tables.forEach((tableConfig, tableIndex) => {
const tableCache = new CacheManager(tableConfig, tableConfig.content)
chunk.tables.forEach((tableIndex) => {
const { cache: tableCache, table: tableConfig } = sheetCacheManager.getSheetTable({ sheetIndex, tableIndex })
if (tableIndex > 0) {
const prevTable = tables[tableIndex - 1]
const prevTable = sheetCacheManager.getSheetTable({ sheetIndex, tableIndex: tableIndex - 1 }).table
COL_OFFSET += prevTable.columns.length + TABLE_CELL_OFFSET
}

const tableContentExtraRows = tableConfig.columns.reduce((acc, col, columnIndex) => {
return Math.max(acc, tableConfig.content.reduce((acc, row, rowIndex) => {
const values = tableCache.getCellValue({ columnIndex, rowIndex })
return values.length - 1 + acc
}, 0))
}, 0)

const hasTitle = !!tableConfig.title
if (hasTitle) {
tableConfig.columns.forEach((_, colIndex) => {
Expand All @@ -197,7 +188,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
}

tableConfig.columns.forEach((column, colIndex) => {
const headerCellRef = utils.encode_cell({ c: colIndex + COL_OFFSET, r: ROW_OFFSET + (rowHasTitle ? 1 : 0) })
const headerCellRef = utils.encode_cell({ c: colIndex + COL_OFFSET, r: ROW_OFFSET + (chunk.hasTitle ? 1 : 0) })
worksheet[headerCellRef] = createCell({
value: column.label,
bordered: params?.bordered ?? true,
Expand All @@ -212,7 +203,7 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
values.forEach((value, valueIndex) => {
const cellRef = utils.encode_cell({
c: colIndex + COL_OFFSET,
r: prevRowHeight + ROW_OFFSET + (rowHasTitle ? 1 : 0) + (valueIndex + 1),
r: prevRowHeight + ROW_OFFSET + (chunk.hasTitle ? 1 : 0) + (valueIndex + 1),
})

worksheet[cellRef] = createCell({
Expand All @@ -230,26 +221,26 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
for (let valueIndex = values.length; valueIndex < maxRowHeight; valueIndex++) {
const cellRef = utils.encode_cell({
c: colIndex + COL_OFFSET,
r: prevRowHeight + ROW_OFFSET + (rowHasTitle ? 1 : 0) + (valueIndex + 1),
r: prevRowHeight + ROW_OFFSET + (chunk.hasTitle ? 1 : 0) + (valueIndex + 1),
})
worksheet[cellRef] = createCell({ value: '', bordered: params?.bordered ?? true })
}
if (values.length === 1) {
worksheet['!merges'].push({
s: { c: colIndex + COL_OFFSET, r: prevRowHeight + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0) },
e: { c: colIndex + COL_OFFSET, r: prevRowHeight + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0) + maxRowHeight - 1 },
s: { c: colIndex + COL_OFFSET, r: prevRowHeight + 1 + ROW_OFFSET + (chunk.hasTitle ? 1 : 0) },
e: { c: colIndex + COL_OFFSET, r: prevRowHeight + 1 + ROW_OFFSET + (chunk.hasTitle ? 1 : 0) + maxRowHeight - 1 },
})
}
}
})

if (tableHasSummary(tableConfig)) {
const summaryRowIndex = tableConfig.content.length + 1 + tableContentExtraRows
const summaryRowIndex = tableConfig.content.length + 1 + tableCache.getNbExtraRows()
for (const summaryIndex in column._ref?.summary ?? []) {
const summary = column._ref?.summary?.[summaryIndex]
const cellRef = utils.encode_cell({
c: +colIndex + COL_OFFSET,
r: summaryRowIndex + ROW_OFFSET + +summaryIndex + (rowHasTitle ? 1 : 0),
r: summaryRowIndex + ROW_OFFSET + +summaryIndex + (chunk.hasTitle ? 1 : 0),
})
if (!summary) {
worksheet[cellRef] = createCell({
Expand Down Expand Up @@ -278,10 +269,10 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
}
})

if (tableContentExtraRows > 0) {
if (tableCache.getNbExtraRows() > 0) {
tableConfig.content.forEach((row, rowIndex) => {
const prevRowHeight = tableCache.getPrevRowsHeight(rowIndex)
const rowStart = prevRowHeight + 1 + ROW_OFFSET + (rowHasTitle ? 1 : 0)
const rowStart = prevRowHeight + 1 + ROW_OFFSET + (chunk.hasTitle ? 1 : 0)
const currentRowHeight = tableCache.getRowMaxHeight(rowIndex)
const start = utils.encode_cell({ c: COL_OFFSET, r: rowStart })
const end = utils.encode_cell({ c: COL_OFFSET + tableConfig.columns.length - 1, r: rowStart + (currentRowHeight - 1) })
Expand All @@ -291,8 +282,8 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
})
})

const { sheetHeight, sheetRange } = computeSheetRange(tableRows)
const colSeparatorIndexes = getColumnSeparatorIndexes({ sheetConfig, offset: TABLE_CELL_OFFSET })
const { height: sheetHeight, range: sheetRange } = sheetCacheManager.getSheetRange({ sheetIndex })
const colSeparatorIndexes = getColumnSeparatorIndexes({ sheetConfig: sheetConfig.sheet, offset: TABLE_CELL_OFFSET })

worksheet['!ref'] = sheetRange
worksheet['!rows'] = Array.from(
Expand All @@ -301,9 +292,9 @@ export class ExcelBuilder<UsedSheetKeys extends string = never> {
)

worksheet['!cols'] = getWorksheetColumnWidths(worksheet, params?.extraLength ?? 5)
.map(({ wch }, index) => ({ wch: colSeparatorIndexes.includes(index) ? sheetConfig.params?.tableSeparatorWidth ?? 25 : wch }))
.map(({ wch }, index) => ({ wch: colSeparatorIndexes.includes(index) ? sheetConfig.sheet.params?.tableSeparatorWidth ?? 25 : wch }))

utils.book_append_sheet(workbook, worksheet, sheetConfig.sheet)
utils.book_append_sheet(workbook, worksheet, sheetConfig.sheet.sheet)
})

workbook.Workbook ??= {}
Expand Down
111 changes: 105 additions & 6 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,20 +358,19 @@ export function createCell(params: {
}
}

// A TABLE CACHE MANAGER, THAT ACCEPTS table: ReturnType<typeof buildSheetConfig>[number]['tables'][number] and rows: GenericObject[] and returns a cache object

export class CacheManager {
export class TableCacheManager {
private table: ReturnType<typeof buildSheetConfig>[number]['tables'][number]
private rows: GenericObject[]
private rowMaxHeight: Map<number, number> = new Map()
private prevRowsHeight: Map<number, number> = new Map()
private cellValue: Map<string, BaseCellValue[]> = new Map()
private nbExtraRows: number = 0

constructor(table: ReturnType<typeof buildSheetConfig>[number]['tables'][number], rows: GenericObject[]) {
constructor(table: ReturnType<typeof buildSheetConfig>[number]['tables'][number]) {
this.table = table
this.rows = rows
this.rows = table.content

rows.forEach((row, rowIndex) => {
table.content.forEach((row, rowIndex) => {
const rowHeight = getRowMaxHeight({ tableConfig: this.table, rowIndex })
const _prevRowsHeight = (this.getPrevRowsHeight(rowIndex - 1) ?? 0) + (this.getRowMaxHeight(rowIndex - 1) ?? 0)
this.rowMaxHeight.set(rowIndex, rowHeight)
Expand All @@ -382,6 +381,13 @@ export class CacheManager {
this.cellValue.set(`${columnIndex}:${rowIndex}`, cellValue)
})
})

this.nbExtraRows = table.columns.reduce((acc, _, columnIndex) => {
return Math.max(acc, table.content.reduce((acc, _, rowIndex) => {
const values = this.getCellValue({ columnIndex, rowIndex })
return values.length - 1 + acc
}, 0))
}, 0)
}

getPrevRowsHeight(rowIndex: number) {
Expand All @@ -395,4 +401,97 @@ export class CacheManager {
getCellValue({ columnIndex, rowIndex }: { columnIndex: number, rowIndex: number }) {
return this.cellValue.get(`${columnIndex}:${rowIndex}`) ?? []
}

getNbExtraRows() {
return this.nbExtraRows
}
}

function getSheetWidth(sheetRows: Array<ReturnType<typeof buildSheetConfig>[number]['tables']>) {
return sheetRows.reduce((acc, tables) => {
const rowWidth = tables.reduce((acc, table) => {
const tableWidth = table.columns.length
return acc + tableWidth + 1
}, 0)
return Math.max(acc, rowWidth)
}, 0)
}

function getSheetHeight(
sheetChunks: Array<ReturnType<typeof buildSheetConfig>[number]['tables']>,
tableCaches: Map<number, { table: ReturnType<typeof buildSheetConfig>[number]['tables'][number], cache: TableCacheManager }>,
) {
return sheetChunks.reduce((acc, tables) => {
const chunkHeight = tables.reduce((acc, _, tableIndex) => {
const { table, cache } = tableCaches.get(tableIndex)!
const hasTitle = !!table.title
const summaryRowLength = tableSummaryRowLength(table)

const maxRowSpan = table.columns.reduce((max, _, columnIndex) => {
return Math.max(max, table.content.reduce((maxRow, _, rowIndex) => {
const values = cache.getCellValue({ columnIndex, rowIndex })
return Array.isArray(values) ? Math.max(maxRow, values.length) : maxRow
}, 1))
}, 1)

const tableHeight = (table.content.length * maxRowSpan) + summaryRowLength + (hasTitle ? 1 : 0)
return Math.max(acc, tableHeight)
}, 0)
return acc + chunkHeight + 1
}, 0)
}

function getSheetRange(params: { width: number, height: number }) {
return utils.encode_range({ s: { c: 0, r: 0 }, e: { c: params.width - 1, r: params.height - 1 } }) // Adjust end column and row index by subtracting 1
}

export class SheetCacheManager {
private computedSheets: Map<number, {
sheet: ReturnType<typeof buildSheetConfig>[number]
tables: Map<number, { table: ReturnType<typeof buildSheetConfig>[number]['tables'][number], cache: TableCacheManager }>
chunks: Array<{
tables: Array<number>
maxHeight: number
hasTitle: boolean
}>
range: { height: number, width: number, range: string }
}> = new Map()

constructor(sheets: ReturnType<typeof buildSheetConfig>) {
sheets.forEach((sheet, sheetIndex) => {
const chunks = splitIntoChunks(sheet.tables, sheet.params?.tablesPerRow)
const tables = new Map<number, { table: ReturnType<typeof buildSheetConfig>[number]['tables'][number], cache: TableCacheManager }>()

sheet.tables.forEach((table, tableIndex) => tables.set(tableIndex, { table, cache: new TableCacheManager(table) }))
const width = getSheetWidth(chunks)
const height = getSheetHeight(chunks, tables)
const range = getSheetRange({ width, height })
this.computedSheets.set(sheetIndex, {
sheet,
tables,
chunks: chunks.map((tables, chunkIndex) => ({
tables: tables.map((_, tableIndex) => tableIndex + chunkIndex * (sheet.params?.tablesPerRow ?? 1)),
maxHeight: getSheetChunkMaxHeight(tables),
hasTitle: tables.some(table => !!table.title),
})),
range: { height, width, range },
})
})
}

getSheets() {
return Array.from(this.computedSheets.values())
}

getSheetChunk(params: { sheetIndex: number, chunkIndex: number }) {
return this.computedSheets.get(params.sheetIndex)?.chunks[params.chunkIndex]
}

getSheetTable(params: { sheetIndex: number, tableIndex: number }) {
return this.computedSheets.get(params.sheetIndex)!.tables.get(params.tableIndex)!
}

getSheetRange(params: { sheetIndex: number }) {
return this.computedSheets.get(params.sheetIndex)!.range
}
}
1 change: 0 additions & 1 deletion test/financial-report.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from 'node:fs'
import { describe, it } from 'vitest'

import { ExcelBuilder, ExcelSchemaBuilder } from '../src'
import { financialReportExcel } from '../docs/.examples/financial-report/file'

describe('should generate the example excel', () => {
Expand Down
7 changes: 1 addition & 6 deletions test/play.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,16 @@ describe('should generate the play excel file', () => {
.column('name', { key: 'name' })
.build()

console.info('Schema built')
console.time('Generate data')
const users: User[] = Array.from({ length: 400000 }, (_, i) => ({
const users: User[] = Array.from({ length: 100000 }, (_, i) => ({
id: i.toString(),
name: 'John',

}))
console.timeEnd('Generate data')

console.time('build')
const file = ExcelBuilder.create()
.sheet('Sheet1', { tablesPerRow: 2 })
.addTable({ data: users, schema, title: 'Table 1' })
.build({ output: 'buffer' })
console.timeEnd('build')

fs.writeFileSync('./examples/playground.xlsx', file)
})
Expand Down

0 comments on commit 418e7ff

Please sign in to comment.