diff --git a/src/sheet/sheet-templater.ts b/src/sheet/sheet-templater.ts index 6aa9e15..ada10f8 100644 --- a/src/sheet/sheet-templater.ts +++ b/src/sheet/sheet-templater.ts @@ -18,7 +18,7 @@ export interface TemplatableCell { cloneWithTextContent(content: string): ThisType; } -type Indexable2DArray = Record>; +export type Indexable2DArray = Record>; export function createTemplaterNoArgsFunction( call: () => R, diff --git a/src/xlsx.ts b/src/xlsx.ts index bfe395e..4607ce6 100644 --- a/src/xlsx.ts +++ b/src/xlsx.ts @@ -7,13 +7,13 @@ import { } from "fast-xml-parser"; import { startVisiting } from "./visitor-editor"; import { Sheet } from "./sheet/sheet"; +import { SheetTemplater, TemplatableCell } from "./sheet/sheet-templater"; const SHARED_STRINGS_ENTRY = "xl/sharedStrings.xml"; export async function xlsxFillTemplate( xlsx: ReadableStream, output: WritableStream, - // @ts-expect-error will be used later input: any, ) { const zipWriter = new ZipWriter(output); @@ -53,6 +53,7 @@ export async function xlsxFillTemplate( const result = await templater.template( await streamToText(contentStream.readable), + input, ); await zipWriter.add(entry.filename, new BlobReader(new Blob([result]))); @@ -61,6 +62,52 @@ export async function xlsxFillTemplate( zipWriter.close(); } +class XlsxCell implements TemplatableCell { + cell: any; + text: string; + + constructor(cell: any) { + // make sure it has the `v` inside of the cell + const cellV = cell["v"]; + + if (cellV === undefined) + throw new Error("Invalid cell, does not contain 'v'"); + + this.cell = cell; + this.text = String(cellV); + + delete this.cell["v"]; + } + + buildCell(reference: `${string}${number}`): any { + this.cell["@_r"] = reference; + this.cell["v"] = this.text; + + if (isNumeric(this.text)) { + this.cell["@_t"] = "n"; + } else { + this.cell["@_t"] = "str"; + } + + return this.cell; + } + + getTextContent(): string { + return this.text; + } + + editTextContent(content: string): XlsxCell { + this.text = content; + return this; + } + + cloneWithTextContent(content: string): XlsxCell { + const clone: XlsxCell = Object.assign({}, this); + clone.text = content; + return clone; + } +} + class XlsxTemplater { // a list of raw XML, make sure to pass it as-is. tends to be tags private sharedStrings: any[] = []; @@ -89,7 +136,7 @@ class XlsxTemplater { console.log(this.sharedStrings); } - async template(templateContent: string): Promise { + async template(templateContent: string, data: any): Promise { const options: X2jOptions & XmlBuilderOptions = { ignoreAttributes: false, parseTagValue: false, @@ -110,17 +157,91 @@ class XlsxTemplater { // then we turn this sheet into a Sheet object so we can work with it easier const extractedData = await this.extract(sheetFilled); - console.log(extractedData); - console.log(extractedData.sheet.getSheet()); + // pass the sheet into SheetTemplater for it to do the actual templating + const templater = new SheetTemplater(extractedData.sheet, { + rowInfo: extractedData.rowInfo, + colInfo: { ...extractedData.colInfo }, + }); + + const templateResult = templater.interpret(data); + if (templateResult.status === "failed") { + throw new Error(templateResult.error.message); + } + + const templatedSheet = templateResult.result; + + // "inject" is a bit misleading, we're essentially injecting the cells that + // have been templated into the original sheet, without changing anything + // else other than the fields that do contain the cols, rows and cells. + const injectedSheet = this.inject( + templatedSheet.sheet, + sheetFilled, + templatedSheet.rowInfo ?? {}, + templatedSheet.colInfo ?? {}, + ); + + console.log("injected"); + console.log(JSON.stringify(injectedSheet, null, 2)); const builder = new XMLBuilder(options); - const result: string = builder.build(sheetFilled); + const result: string = builder.build(injectedSheet); return result; } + async inject( + sheetData: Sheet, + xlsxData: any, + rowInfo: Record, + colInfo: Record, + ): Promise { + const rows: Record = {}; + + const { rowBound, colBound } = sheetData.getBounds(); + sheetData.optimizeSheet({ rowBound, colBound }); + + for (let r = 0; r <= rowBound; r++) { + const row: any[] = []; + + for (let c = 0; c <= colBound; c++) { + const cell = sheetData.getCell(c, r); + if (cell === null) continue; + + row.push(cell.buildCell(createAddressNumber(c, r))); + } + + rows[r] = { ...rowInfo[r], c: row.length === 1 ? row[0] : row }; + } + + const visited = await startVisiting(xlsxData, { + before: {}, + after: { + col: [ + () => { + return { + newObj: Object.keys(colInfo) + .toSorted((a, b) => parseInt(a) - parseInt(b)) + .map((i) => colInfo[parseInt(i)]), + }; + }, + ], + sheetData: [ + () => { + return { + newObj: { + row: rows, + }, + }; + }, + ], + }, + }); + + return visited; + } + async extract(parsedSheet: any): Promise<{ - sheet: Sheet; + sheet: Sheet; rowInfo: Record; colInfo: (any & { min: number; max: number })[]; mergeInfo: { @@ -128,7 +249,7 @@ class XlsxTemplater { end: { col: number; row: number }; }[]; }> { - const sheet = new Sheet(); + const sheet = new Sheet(); const rowInfo: Record = {}; const colInfo: (any & { min: number; max: number })[] = []; const mergeInfo: { @@ -202,7 +323,7 @@ class XlsxTemplater { const { col, row } = parseAddressNumber(cell["@_r"]); delete cell["@_r"]; - sheet.setCell(col, row, cell); + sheet.setCell(col, row, new XlsxCell(cell)); } }, ], @@ -279,6 +400,22 @@ function parseAddressNumber(address: string): { row: number; col: number } { return { row, col }; } +function createAddressNumber(col: number, row: number): `${string}${number}` { + if (col < 0 || row < 0) { + throw new Error("Column and row must be non-negative."); + } + + const oneIndexedRow = row + 1; + + let columnLetter = ""; + while (col >= 0) { + columnLetter = String.fromCharCode(65 + (col % 26)) + columnLetter; + col = Math.floor(col / 26) - 1; + } + + return `${columnLetter}${oneIndexedRow}`; +} + async function streamToText( stream: ReadableStream, ): Promise {