safeer-pdf-generator
Version:
Framework-agnostic PDF generation library with chunking, merging, S3 upload, and email delivery
300 lines • 13 kB
JavaScript
import { validatePdfOptions, validateS3Config, validateEmailConfig, validateLocalFsConfig, } from '../utils/guards.js';
import { defaultPdfOptions, mergeDefaults } from '../config/defaults.js';
import { PdfEngine } from '../core/PdfEngine.js';
import { MergeService } from '../core/MergeService.js';
import { ChunkCoordinator } from '../core/ChunkCoordinator.js';
import { resolveTemplate } from '../templates/registry.js';
import { createS3Uploader } from '../integrations/s3/S3Uploader.js';
import { createLocalFsWriter } from '../integrations/localfs/LocalFsWriter.js';
import { createEmailSender } from '../integrations/email/EmailSender.js';
import { WebhookDispatcher } from '../integrations/webhook/WebhookDispatcher.js';
import { PdfGenerationError } from '../core/errors.js';
import { Timer } from '../utils/timing.js';
/**
* Generate PDF from data and options
*/
export async function generatePdf(options) {
const timer = new Timer();
const startTime = Date.now();
// Merge with defaults and validate
const mergedOptions = mergeDefaults(defaultPdfOptions, options);
validatePdfOptions(mergedOptions);
// Ensure required fields are present after validation
if (!mergedOptions.title || !mergedOptions.data || !mergedOptions.columns) {
throw new PdfGenerationError('Missing required fields: title, data, and columns must be provided', 'validation');
}
// Cast to full options since we've validated required fields
const validatedOptions = mergedOptions;
const logger = validatedOptions.logging;
const events = validatedOptions.events;
logger.info(`Starting PDF generation: "${validatedOptions.title}"`);
logger.debug(`Data rows: ${validatedOptions.data.length}, Columns: ${validatedOptions.columns.length}`);
// Emit generation:started event
if (events) {
events.emitEvent('generation:started', {
title: validatedOptions.title,
rowCount: validatedOptions.data.length,
timestamp: new Date().toISOString(),
});
}
// Initialize services
const pdfEngine = new PdfEngine(validatedOptions.puppeteer, validatedOptions.pdf, logger);
const mergeService = new MergeService(logger);
const chunkCoordinator = new ChunkCoordinator(logger, validatedOptions.hooks);
try {
// Resolve template
const templateCompiler = resolveTemplate(validatedOptions.template);
timer.mark('template-resolved');
// Process chunks
logger.info('Processing data chunks...');
const pdfBuffers = await chunkCoordinator.processChunks(validatedOptions, templateCompiler, pdfEngine);
timer.mark('chunks-processed');
// Merge PDFs if multiple chunks
let finalBuffer;
if (pdfBuffers.length === 1) {
finalBuffer = pdfBuffers[0];
logger.debug('Single chunk - no merging needed');
}
else {
logger.info(`Merging ${pdfBuffers.length} PDF chunks...`);
// Execute before merge hook
if (validatedOptions.hooks?.beforeMerge) {
await validatedOptions.hooks.beforeMerge(pdfBuffers);
}
finalBuffer = await mergeService.mergePdfs(pdfBuffers);
// Execute after merge hook
if (validatedOptions.hooks?.afterMerge) {
await validatedOptions.hooks.afterMerge(finalBuffer);
}
}
timer.mark('pdf-merged');
// Add page numbers to the final PDF
logger.info('Adding page numbers to final PDF...');
finalBuffer = await mergeService.addPageNumbers(finalBuffer, {
userInfo: validatedOptions.userInfo,
isRTL: validatedOptions.locale === 'ar',
footer: validatedOptions.footer,
});
timer.mark('page-numbers-added');
// Get PDF info
const pdfInfo = await mergeService.getPdfInfo(finalBuffer);
timer.mark('pdf-analyzed');
// Generate filename
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '-')
.replace('Z', '');
const fileName = `${validatedOptions.title.replace(/\s+/g, '-')}-${timestamp}.pdf`;
// Prepare result
const result = {
buffer: finalBuffer,
sizeBytes: finalBuffer.length,
pageCount: pdfInfo.pages,
durationMs: Date.now() - startTime,
fileName,
metadata: {
title: validatedOptions.title,
chunkCount: pdfBuffers.length,
rows: validatedOptions.data.length,
generatedAt: new Date().toISOString(),
},
};
// Handle S3 upload if configured
if (validatedOptions.s3) {
logger.info('Uploading PDF to S3...');
validateS3Config(validatedOptions.s3);
const s3Uploader = createS3Uploader(validatedOptions.s3, logger);
const s3Result = await s3Uploader.upload(finalBuffer, fileName, 'application/pdf');
result.s3 = s3Result;
timer.mark('s3-uploaded');
logger.info(`PDF uploaded to S3: ${s3Result.url}`);
// Emit s3:upload:complete event
if (events) {
events.emitEvent('s3:upload:complete', {
url: s3Result.url,
key: s3Result.key,
requestPayload: validatedOptions.webhook
? validatedOptions.webhook.metadata
: undefined,
});
}
}
// Handle local filesystem write if configured
if (validatedOptions.localFs) {
logger.info('Writing PDF to local filesystem...');
validateLocalFsConfig(validatedOptions.localFs);
const localFsWriter = createLocalFsWriter(validatedOptions.localFs, logger);
const localFsResult = await localFsWriter.write(finalBuffer, fileName);
result.localFs = localFsResult;
timer.mark('localfs-written');
logger.info(`PDF written to ${localFsResult.path}`);
if (events) {
events.emitEvent('file:saved', {
path: localFsResult.path,
sizeBytes: localFsResult.sizeBytes,
});
}
}
// Handle email sending if configured
if (validatedOptions.email) {
logger.info('Sending PDF email...');
validateEmailConfig(validatedOptions.email);
const emailSender = createEmailSender(logger);
const emailResult = await emailSender.send(validatedOptions.email, finalBuffer, fileName, result.s3?.url);
result.email = {
sent: emailResult.sent,
to: validatedOptions.email.to,
messageId: emailResult.messageId,
error: emailResult.error,
};
timer.mark('email-sent');
logger.info(`Email ${emailResult.sent ? 'sent successfully' : 'failed'}: ${validatedOptions.email.to}`);
// Emit email:sent event
if (events && emailResult.sent) {
events.emitEvent('email:sent', {
to: validatedOptions.email.to,
messageId: emailResult.messageId,
});
}
}
const totalTime = timer.elapsed();
logger.info(`PDF generation completed: ${result.pageCount} pages, ` +
`${(result.sizeBytes / 1024 / 1024).toFixed(2)}MB, ` +
`${totalTime.toFixed(1)}ms`);
// Emit generation:complete event
if (events) {
events.emitEvent('generation:complete', { result });
}
// Dispatch webhook if configured (non-blocking — failure only logs a warning)
if (validatedOptions.webhook) {
const webhookConfig = validatedOptions.webhook;
const dispatcher = new WebhookDispatcher(webhookConfig.url, webhookConfig.secret, logger, webhookConfig.timeoutMs);
// Fire and log — do not block the result
dispatcher.dispatch({
s3Url: result.s3?.url,
s3Key: result.s3?.key,
title: result.metadata.title,
fileName: result.fileName,
sizeBytes: result.sizeBytes,
pageCount: result.pageCount,
durationMs: result.durationMs,
generatedAt: result.metadata.generatedAt,
metadata: webhookConfig.metadata,
}).then((webhookResult) => {
if (webhookResult.success) {
logger.info(`Webhook dispatched successfully (${webhookResult.statusCode})`);
}
else {
logger.warn(`Webhook dispatch failed: ${webhookResult.error}`);
}
}).catch((err) => {
logger.warn(`Webhook dispatch error: ${err}`);
});
}
return result;
}
catch (error) {
const elapsed = timer.elapsed();
logger.error(`PDF generation failed after ${elapsed.toFixed(1)}ms:`, error);
// Emit error event
if (events) {
const err = error instanceof Error ? error : new Error(String(error));
events.emitEvent('error', {
error: err,
phase: 'generation',
stack: err.stack,
});
}
throw new PdfGenerationError(`PDF generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'generation', error);
}
finally {
// Clean up resources
try {
await pdfEngine.close();
}
catch (cleanupError) {
logger.warn('Error during cleanup:', cleanupError);
}
}
}
/**
* Generate chunked PDF (alias for generatePdf with chunking enabled)
*/
export async function generateChunkedPdf(options) {
const chunkedOptions = {
...options,
chunking: {
enabled: true,
chunkSize: options.chunking?.chunkSize || 100,
maxConcurrency: options.chunking?.maxConcurrency || 2,
},
};
return generatePdf(chunkedOptions);
}
/**
* Generate PDF with optimal chunking based on data size
*/
export async function generateOptimizedPdf(options) {
const dataLength = options.data.length;
const optimalChunking = ChunkCoordinator.calculateOptimalChunking(dataLength);
const optimizedOptions = {
...options,
chunking: {
...options.chunking,
...optimalChunking,
},
};
if (options.logging) {
options.logging.info(`Using optimal chunking: ${optimalChunking.chunkSize} rows/chunk, ` +
`${optimalChunking.maxConcurrency} concurrent, ` +
`enabled: ${optimalChunking.enabled}`);
}
return generatePdf(optimizedOptions);
}
/**
* Estimate PDF generation time and resources
*/
export function estimatePdfGeneration(options) {
const dataLength = options.data?.length || 0;
const chunkSize = options.chunking?.chunkSize || 100;
const maxConcurrency = options.chunking?.maxConcurrency || 2;
const chunks = Math.ceil(dataLength / chunkSize);
const avgRowsPerPage = 50; // Rough estimate
const estimatedPages = Math.max(1, Math.ceil(dataLength / avgRowsPerPage));
// Time estimation. Chunks render in parallel pools of `maxConcurrency`,
// so the per-chunk overhead scales with the number of parallel batches,
// not the total chunk count.
const concurrency = Math.max(1, maxConcurrency);
const parallelChunkBatches = Math.ceil(chunks / concurrency);
const baseTimePerChunk = 800; // warm-browser average
const timePerRow = 1; // ~1ms per row on warm Chromium with a typical template
const estimatedTimeMs = parallelChunkBatches * baseTimePerChunk + dataLength * timePerRow;
// Memory estimation
const memoryEstimate = ChunkCoordinator.estimateMemoryUsage(dataLength, chunkSize, maxConcurrency);
const recommendations = [];
if (dataLength > 10000) {
recommendations.push('Consider increasing chunk size for large datasets');
}
if (dataLength < 100 && chunks > 1) {
recommendations.push('Disable chunking for small datasets');
}
if (memoryEstimate.estimatedMB > 1000) {
recommendations.push('Reduce concurrency or chunk size to lower memory usage');
}
if (estimatedTimeMs > 300000) {
recommendations.push('Consider processing in smaller batches for very long operations');
}
return {
estimatedTimeMs,
estimatedMemoryMB: memoryEstimate.estimatedMB,
estimatedPages,
chunking: {
chunks,
rowsPerChunk: chunkSize,
concurrency: maxConcurrency,
},
recommendations,
};
}
//# sourceMappingURL=generatePdf.js.map