safeer-pdf-generator
Version:
Framework-agnostic PDF generation library with chunking, merging, S3 upload, and email delivery
248 lines • 10.9 kB
JavaScript
import { ChunkProcessingError, PdfGenerationError } from '../core/errors.js';
import { noOpLogger } from '../config/defaults.js';
import { Timer } from '../utils/timing.js';
import { convertImageToBase64 } from '../utils/image.js';
/**
* Coordinates the processing of data chunks into PDF buffers
*/
export class ChunkCoordinator {
constructor(logger = noOpLogger, hooks = {}) {
this.logger = logger;
this.hooks = hooks;
}
/**
* Process data into chunks and generate PDF buffers
*/
async processChunks(options, templateCompiler, pdfEngine) {
const timer = new Timer();
const { data, chunking = { enabled: true, chunkSize: 100, maxConcurrency: 2 } } = options;
// If chunking is disabled or data is small, process as single chunk
if (!chunking.enabled || data.length <= (chunking.chunkSize || 100)) {
this.logger.info('Processing data as single chunk');
const buffer = await this.processSingleChunk(options, templateCompiler, pdfEngine);
return [buffer];
}
const chunkSize = chunking.chunkSize || 100;
const maxConcurrency = chunking.maxConcurrency || 2;
const totalChunks = Math.ceil(data.length / chunkSize);
this.logger.info(`Processing ${data.length} rows in ${totalChunks} chunks ` +
`(size: ${chunkSize}, concurrency: ${maxConcurrency})`);
// Prepare enhanced options with logo processing
const enhancedOptions = await this.prepareChunkOptions(options);
// Execute before merge hook
if (this.hooks.beforeMerge) {
timer.mark('before-merge-hook');
await this.hooks.beforeMerge([]);
timer.mark('before-merge-hook-end');
}
const chunks = [];
// Generate HTML for all chunks first
for (let i = 0; i < totalChunks; i++) {
const startIdx = i * chunkSize;
const endIdx = Math.min(startIdx + chunkSize, data.length);
const chunkData = data.slice(startIdx, endIdx);
const chunkOptions = {
...enhancedOptions,
data: chunkData,
chunkInfo: {
current: i + 1,
total: totalChunks,
startRow: startIdx + 1,
endRow: endIdx,
},
};
// Execute before chunk render hook
if (this.hooks.beforeChunkRender) {
await this.hooks.beforeChunkRender(chunkOptions);
}
try {
const { html, header, footer } = templateCompiler(chunkOptions);
// Apply HTML transform hook if available
let processedHtml = html;
if (this.hooks.transformHtml) {
processedHtml = this.hooks.transformHtml(html, 'pre');
}
chunks.push({ html: processedHtml, header, footer });
this.logger.debug(`Generated HTML for chunk ${i + 1}/${totalChunks} (rows ${startIdx + 1}-${endIdx})`);
}
catch (error) {
throw new ChunkProcessingError(`Failed to generate HTML for chunk ${i + 1}`, i, error);
}
}
this.logger.info(`Generated HTML for all ${totalChunks} chunks`);
timer.mark('html-generation-complete');
// Generate PDFs from HTML chunks
try {
const pdfBuffers = await pdfEngine.generateChunks(chunks, maxConcurrency);
// Execute after chunk render hooks
if (this.hooks.afterChunkRender) {
for (let i = 0; i < pdfBuffers.length; i++) {
await this.hooks.afterChunkRender(pdfBuffers[i], {
chunkIndex: i,
totalChunks,
});
}
}
const totalTime = timer.elapsed();
this.logger.info(`Completed chunk processing: ${totalChunks} chunks, ` + `${totalTime.toFixed(1)}ms total`);
return pdfBuffers;
}
catch (error) {
throw new PdfGenerationError(`Chunk processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'chunk-processing', error);
}
}
/**
* Process data as a single chunk
*/
async processSingleChunk(options, templateCompiler, pdfEngine) {
const timer = new Timer();
const enhancedOptions = await this.prepareChunkOptions(options);
// Execute before chunk render hook
if (this.hooks.beforeChunkRender) {
await this.hooks.beforeChunkRender(enhancedOptions);
}
try {
const { html, header, footer } = templateCompiler(enhancedOptions);
// Apply HTML transform hook if available
let processedHtml = html;
if (this.hooks.transformHtml) {
processedHtml = this.hooks.transformHtml(html, 'pre');
processedHtml = this.hooks.transformHtml(processedHtml, 'post');
}
timer.mark('html-generated');
const pdfBuffer = await pdfEngine.generatePdf(processedHtml, header, footer);
// Execute after chunk render hook
if (this.hooks.afterChunkRender) {
await this.hooks.afterChunkRender(pdfBuffer, { chunkIndex: 0, totalChunks: 1 });
}
const totalTime = timer.elapsed();
this.logger.info(`Single chunk processing completed in ${totalTime.toFixed(1)}ms`);
return pdfBuffer;
}
catch (error) {
throw new ChunkProcessingError(`Failed to process single chunk: ${error instanceof Error ? error.message : 'Unknown error'}`, 0, error);
}
}
/**
* Prepare options with enhanced features like logo processing
*/
async prepareChunkOptions(options) {
const enhancedOptions = { ...options };
// Process company logo if present
if (options.userInfo?.companyLogo) {
try {
this.logger.debug('Converting company logo to base64...');
const logoFetcher = options.logoFetch?.fetcher;
const companyLogoBase64 = await convertImageToBase64(options.userInfo.companyLogo, this.logger, logoFetcher);
enhancedOptions.userInfo = {
...options.userInfo,
companyLogoBase64,
};
this.logger.debug('Company logo processed successfully');
}
catch (error) {
this.logger.warn(`Failed to process company logo: ${error}`);
// Continue without logo rather than failing
enhancedOptions.userInfo = {
...options.userInfo,
companyLogoBase64: '',
};
}
}
// Apply column value transformations if hook is provided
if (this.hooks.transformColumnValue && enhancedOptions.data.length > 0) {
enhancedOptions.data = enhancedOptions.data.map((row, rowIndex) => {
const transformedRow = { ...row };
for (const column of enhancedOptions.columns) {
if (transformedRow.hasOwnProperty(column.dataIndex)) {
try {
const originalValue = transformedRow[column.dataIndex];
const transformedValue = this.hooks.transformColumnValue(originalValue, column, row);
transformedRow[column.dataIndex] = transformedValue;
}
catch (error) {
this.logger.warn(`Failed to transform column ${column.dataIndex} for row ${rowIndex}: ${error}`);
// Keep original value on transformation error
}
}
}
return transformedRow;
});
}
return enhancedOptions;
}
/**
* Calculate optimal chunk configuration based on data size and system resources
*/
static calculateOptimalChunking(dataLength, availableMemoryMB) {
// Disable chunking for small datasets
if (dataLength <= 50) {
return { chunkSize: dataLength, maxConcurrency: 1, enabled: false };
}
// Base calculations
let chunkSize = 100;
let maxConcurrency = 2;
// Adjust based on data size
if (dataLength > 10000) {
chunkSize = 200;
maxConcurrency = 3;
}
else if (dataLength > 5000) {
chunkSize = 150;
maxConcurrency = 3;
}
else if (dataLength > 1000) {
chunkSize = 100;
maxConcurrency = 2;
}
// Adjust based on available memory if provided
if (availableMemoryMB) {
if (availableMemoryMB < 512) {
chunkSize = Math.min(chunkSize, 50);
maxConcurrency = 1;
}
else if (availableMemoryMB > 2048) {
maxConcurrency = Math.min(maxConcurrency + 1, 5);
}
}
return { chunkSize, maxConcurrency, enabled: true };
}
/**
* Estimate memory usage for chunk processing
*/
static estimateMemoryUsage(_dataLength, chunkSize, maxConcurrency, avgRowSizeKB = 1) {
const chunksInMemory = maxConcurrency;
const rowsPerChunk = chunkSize;
const estimatedChunkSizeMB = (rowsPerChunk * avgRowSizeKB) / 1024;
const estimatedMB = chunksInMemory * estimatedChunkSizeMB * 3; // 3x multiplier for HTML + PDF + overhead
let recommendation = 'Configuration looks good';
if (estimatedMB > 1024) {
recommendation = 'High memory usage expected, consider reducing chunk size or concurrency';
}
else if (estimatedMB < 50) {
recommendation = 'Low memory usage, you could increase concurrency for better performance';
}
return { estimatedMB, recommendation };
}
/**
* Validate chunk configuration
*/
static validateChunkConfig(config) {
const errors = [];
const { chunkSize = 100, maxConcurrency = 2, dataLength } = config;
if (chunkSize < 1) {
errors.push('Chunk size must be at least 1');
}
if (chunkSize > dataLength) {
errors.push('Chunk size cannot be larger than data length');
}
if (maxConcurrency < 1) {
errors.push('Max concurrency must be at least 1');
}
if (maxConcurrency > 10) {
errors.push('Max concurrency should not exceed 10 for stability');
}
return { valid: errors.length === 0, errors };
}
}
//# sourceMappingURL=ChunkCoordinator.js.map